├── .editorconfig ├── .envrc ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── TODO.norg ├── docs ├── README.md ├── api │ ├── functions.md │ ├── hooks.md │ └── properties.md ├── best-practices.md ├── how-it-works.md ├── recipies.md └── security.md ├── esbuild.js ├── examples ├── calculator │ ├── index.html │ └── style.css ├── clock │ ├── index.html │ └── style.css ├── form │ ├── index.html │ ├── signup.css │ └── style.css └── todo-list │ ├── index.html │ └── style.css ├── jest.config.js ├── media ├── banner.png └── banner.svg ├── package.json ├── shell.nix ├── src ├── declarations.ts ├── eval.ts ├── index.ts ├── parser.ts ├── renderer.ts └── utils │ ├── adt.ts │ ├── parser-comb.ts │ └── result.ts ├── tests ├── calc.spec.ts ├── eval.spec.ts ├── fixtures │ ├── signup │ │ └── index.html │ └── todo-app │ │ └── index.html ├── parser.spec.ts ├── signup.spec.ts ├── todo-app.spec.ts └── util.ts ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | use_nix 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | jest: true, 6 | }, 7 | extends: [ 8 | 'eslint:recommended', 9 | 'plugin:@typescript-eslint/recommended', 10 | 'prettier', 11 | ], 12 | overrides: [ 13 | { 14 | env: { 15 | node: true, 16 | }, 17 | files: ['.eslintrc.{js,cjs}'], 18 | parserOptions: { 19 | sourceType: 'script', 20 | }, 21 | }, 22 | ], 23 | parser: '@typescript-eslint/parser', 24 | parserOptions: { 25 | ecmaVersion: 'latest', 26 | sourceType: 'module', 27 | }, 28 | plugins: ['@typescript-eslint', 'prettier'], 29 | rules: { 30 | 'prettier/prettier': 'error', 31 | '@typescript-eslint/no-explicit-any': 'off', 32 | '@typescript-eslint/no-unused-vars': [ 33 | 'warn', // or "error" 34 | { 35 | argsIgnorePattern: '^_', 36 | varsIgnorePattern: '^_', 37 | caughtErrorsIgnorePattern: '^_', 38 | }, 39 | ], 40 | }, 41 | } 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | *.log 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | semi: false 2 | singleQuote: true 3 | bracketSpacing: true 4 | arrowParens: avoid 5 | printWidth: 80 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Akshay Nair 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 | --- 6 | 7 | A ui framework where you only write turing complete CSS. No HTML, no JS, no build system. 8 | 9 | > Disclaimer: Don't use this 10 | 11 | ## Usage 12 | #### Add script tag to the renderer to your html 13 | ```html 14 | 15 | ``` 16 | 17 | #### Open up a style tag or link a stylesheet 18 | The renderer by default uses the body element. You can use `:root` to describe the starting point. 19 | Here's a simple counter example: 20 | ```css 21 | :root { 22 | --counter: '0'; 23 | --cssx-children: div#count button#decr button#incr; 24 | } 25 | 26 | #count::after { content: "Count: " var(--counter); } 27 | 28 | #incr { 29 | --cssx-on-click: update(':root', --counter, calc(get-var(--counter) + 1)); 30 | --cssx-text: "++"; 31 | } 32 | #decr { 33 | --cssx-on-click: update(':root', --counter, calc(get-var(--counter) - 1)); 34 | --cssx-text: "--"; 35 | } 36 | ``` 37 | 38 | ## More examples 39 | Here are a few live examples for you to try out - 40 | - [Here's a calculator example](https://codepen.io/phenax/pen/PoLjJmL?editors=1100) 41 | - [Here's a todo app example](https://codepen.io/phenax/pen/QWzWGaV?editors=1100) 42 | - [Here's a simple counter example](https://codepen.io/phenax/pen/KKbdZep?editors=1100) 43 | - [Here's a digital & analog clock example](https://codepen.io/phenax/pen/KKbKNeb?editors=1100) 44 | - [More in the examples directory](https://github.com/phenax/css-everything/tree/main/examples) 45 | 46 | 47 | ## Docs 48 | - [Read the documentation](https://github.com/phenax/css-everything/tree/main/docs/README.md) to become enlightened. 49 | - [Here's how this works](https://github.com/phenax/css-everything/tree/main/docs/how-it-works.md). 50 | 51 | 52 | 53 | --- 54 | 55 | 56 | ## Frequently Acquisitioned Queries 57 | ### Why? 58 | Why not? 59 | 60 | ### What? 61 | What? 62 | 63 | ### What time is it? 64 | You can find the answer with [this example](https://codepen.io/phenax/pen/KKbKNeb?editors=1100). 65 | 66 | ### How does it work? 67 | [Here's how it works](https://github.com/phenax/css-everything/tree/main/docs/how-it-works.md). 68 | 69 | ### Is this turing complete? 70 | Yep. Not that you asked, but here's how to calculate factorial of a number. 71 | 72 | ```css 73 | :root { --cssx-children: div#container; } 74 | 75 | #container { 76 | --factorial: func(--n: number) 77 | if(lte(get-var(--n), 1), 1, 78 | calc( 79 | get-var(--n) 80 | * call(--factorial, map(--n: calc(get-var(--n) - 1))) 81 | )); 82 | 83 | --cssx-text: string("7! = ", call(--factorial, map(--n: 7))); 84 | } 85 | ``` 86 | 87 | ### Escape hatches? 88 | - If you want to directly render some raw html, you can use `--cssx-disgustingly-set-innerhtml`. 89 | - If you want to run js expressions, you can use the `js-eval` function. Eg: `js-eval("new Date().getSeconds()")` 90 | 91 | ### Does it need a build step? 92 | No. In fact, this'll probably break if you try to use it with a css preprocessor. 93 | 94 | -------------------------------------------------------------------------------- /TODO.norg: -------------------------------------------------------------------------------- 1 | * Now 2 | - (x) Documentation 3 | - (x) string concatenation/interpolation 4 | - (x) some way to de-quotify values? 5 | - (x) re-quotify value 6 | - (x) analog + digital clock example 7 | - (x) error handling try 8 | - (x) Scoped catch on try 9 | - (x) `do` expression 10 | - (x) `let` expression 11 | - (x) `h` declarations 12 | - ( ) `has-class` 13 | - ( ) `add-class` & `remove-class` should use self if id is not specified? 14 | - ( ) Update `--cssx-text` on update 15 | 16 | * Later 17 | - ( ) keyboard events 18 | - ( ) Tail recursion optimization 19 | - ( ) Additional events 20 | - ( ) Improve parser error messages 21 | - ( ) Improve eval error messages 22 | - ( ) filter for on update on specific properties 23 | - ( ) FFI interface to declare functions 24 | 25 | * Maybe 26 | - ( ) `--cssx-use-properties: --parent-prop;` to trigger nested property update 27 | - ( ) Evaluate `calc` 28 | - ( ) *server-side css*? Why the fuck not!? 29 | 30 | * Done 31 | - (x) Hydrate existing elements instead of re-creating 32 | - (x) `load-cssx` functions 33 | - (x) `get-variable` 34 | - (x) `update-variable` 35 | - (x) Use css units for `delay` function 36 | - (x) Specify node type - `button(id)` or `button#id` 37 | - (x) attributes 38 | - (x) `--cssx-text` (and maybe `--cssx-html`?) 39 | - (x) dom tests 40 | - (x) `attr` function 41 | - (x) `set-attr` should allow specifying id? 42 | - (x) `set-attr` + remove attribute? 43 | - (x) `pair` parsing 44 | - (x) `selector` parsing 45 | - (x) `map` data structure 46 | - (x) component system (with variables. `instance(button#my-btn)`) 47 | - (x) More complex selector support for cssx-children 48 | - (x) `add-element` & `remove-element` 49 | - (x) conditionals 50 | - (x) on update 51 | - (x) access child from an instance (update checkbox) 52 | - (x) access instance from child (delete task) 53 | - (x) focus blur events 54 | - (x) Refactor eval to return EvalValue 55 | - (x) Functions. `--my-func: func(if(get-var(--x), get-var(--y), ""))`. 56 | - (x) Function calls. `call(get-var(--my-func), --x: 6, --y: 2)` 57 | 58 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | - [API/functions](./api/functions.md) 4 | - [API/hooks](./api/hooks.md) 5 | - [API/properties](./api/properties.md) 6 | - [How it works?](./how-it-works.md) 7 | - [Security](./security.md) 8 | - [Common recipies](./recipies.md) 9 | 10 | -------------------------------------------------------------------------------- /docs/api/functions.md: -------------------------------------------------------------------------------- 1 | # Functions 2 | 3 | ```typescript 4 | type custom-property-name = `--${string}` 5 | type selector = string // Any css selector or identifier (used as id) 6 | type condition = string // empty string, 0, 'false', "'false'" and "\"false\"" are all false, the rest are fine. Don't ask. 7 | type duration = number | `${number}ms` | `${number}s` 8 | type pair = string: any 9 | ``` 10 | 11 | === 12 | 13 | 14 | ## Core 15 | 16 | ### get-var / var 17 | Get css custom property from an element. Basically var but evaluated lazily. 18 | 19 | NOTE: Avoid using `var` inside cssx expressions. 20 | 21 | ```typescript 22 | function get-var(custom-property-name): string 23 | function get-var(custom-property-name, default-value): string 24 | ``` 25 | 26 | Example - 27 | ```css 28 | #my-element { 29 | --cssx-text: get-var(--some-variable); 30 | } 31 | ``` 32 | 33 | ### calc 34 | Do some math using the calc syntax. Not 100% compatible but it'll do. 35 | 36 | Supported units: rem, em, px, %, ms, s 37 | 38 | ```typescript 39 | function calc(calc-expr): string 40 | ``` 41 | 42 | Example - 43 | ```css 44 | #my-element { 45 | --cssx-text: calc(1 + 5 * get-var(--some-variable)); 46 | } 47 | ``` 48 | 49 | 50 | #### update 51 | Update a css custom property on an element 52 | 53 | ```typescript 54 | function update(custom-property-name, string): void 55 | function update(selector, custom-property-name, string): string 56 | ``` 57 | 58 | Example - 59 | ```css 60 | #my-element { 61 | --cssx-on-click: update(some-el, --text, attr(input-element, 'value')); 62 | 63 | --cssx-children: input#input-element #some-el; 64 | } 65 | 66 | #some-el { --text: "default"; } 67 | #some-el::after { content: var(--text); } 68 | ``` 69 | 70 | 71 | ### if 72 | If expression. You know how this one goes. If truthy, it'll pick the second argument, else the third. 73 | 74 | ```typescript 75 | function if(condition, any, any): any 76 | ``` 77 | 78 | Example - 79 | ```css 80 | #my-element { 81 | --boolean: false; 82 | 83 | --cssx-on-update: 84 | update(background-color, 85 | if( 86 | get-var(--boolean) 87 | 'DarkGoldenRod', 88 | 'PapayaWhip' 89 | ) 90 | ); 91 | } 92 | ``` 93 | 94 | ### map 95 | A constructor function to create a map of key value pairs. 96 | 97 | ```typescript 98 | function map(...pair): map 99 | ``` 100 | 101 | Example - 102 | ```css 103 | #my-element { 104 | --cssx-children: instance( 105 | div#some-element, 106 | map(--prop1: "hello", --prop2: "world") 107 | ); 108 | } 109 | ``` 110 | 111 | ### seq 112 | > WIP Docs 113 | 114 | ### call 115 | Call a "function". A function is any series of expressions defined in a css custom property. 116 | 117 | NOTE: Every function call creates a new dom node for computing the result. Dom nodes are the call stack. 118 | 119 | ```typescript 120 | function call(var-identifier, map): any 121 | ``` 122 | 123 | Example - 124 | ```css 125 | #my-element { 126 | --factorial: if( 127 | js-eval(string(get-var(--n), '> 1')), 128 | js-eval(string( 129 | get-var(--n), 130 | ' * ', 131 | call(--factorial, map(--n: js-eval(string(get-var(--n), ' - 1')))) 132 | )), 133 | 1 134 | ); 135 | 136 | --cssx-on-mount: js-eval(string( 137 | 'console.log("', 138 | call(--factorial, map(--n: 5)), 139 | '")' 140 | )); 141 | } 142 | ``` 143 | 144 | Or let's just go nuts with functions. 145 | Because we use named properties as arguments and css is cascading, all named properties are implicitly available to everything getting called. 146 | 147 | > Not a design choice, a design consequence. 148 | 149 | So `--left` and `--right` is implicitly available to `--binary-op`. 150 | 151 | NOTE: `func` in `--binary-op` doesn't do anything. It's to make the developer feel better. 152 | 153 | ```css 154 | #my-element { 155 | --binary-op: 156 | func(--left, --op, --right) 157 | js-eval(string(get-var(--left), get-var(--op), get-var(--right))); 158 | --greater-than: call(--binary-op, map(--op: ' >= ')); 159 | --minus: call(--binary-op, map(--op: ' - ')); 160 | --multiply: call(--binary-op, map(--op: ' * ')); 161 | 162 | --factorial: if(call(--greater-than, map(--left: get-var(--n), --right: 1)), 163 | call(--multiply, map( 164 | --left: get-var(--n), 165 | --right: call(--factorial, 166 | map(--n: call(--minus, 167 | map(--left: get-var(--n), --right: 1))) 168 | ) 169 | )), 170 | 1 171 | ); 172 | } 173 | ``` 174 | 175 | ### string 176 | Concatenate strings together / Cast a value to string explicitly 177 | 178 | ```typescript 179 | function string(...string): string 180 | ``` 181 | 182 | ```css 183 | #my-element { 184 | --log-stuff: 'Stuff to log to console'; 185 | --cssx-on-mount: js-eval(string('console.log("', get-var(--log-stuff), '")')); 186 | } 187 | ``` 188 | 189 | ### quotify 190 | Add quotes around a value 191 | 192 | ```typescript 193 | function quotify(string): `'${string}'` 194 | ``` 195 | 196 | ```css 197 | #my-element { 198 | --log-stuff: 'Stuff to log to console'; 199 | --cssx-on-mount: js-eval(string('console.log(', quotify(get-var(--log-stuff)), ')')); 200 | } 201 | ``` 202 | 203 | 204 | ### unquotify 205 | Remove quotes from a value. No example here, use your imagination. 206 | 207 | ```typescript 208 | function quotify(string): string 209 | ``` 210 | 211 | 212 | ### do 213 | Evaluate a series of expressions in sequence and return the last value. 214 | 215 | ```css 216 | #my-element { 217 | --cssx-on-mount: 218 | if(get-var(--some-boolean), 219 | do( 220 | add-class(loading), 221 | delay(1s), 222 | remove-class(loading) 223 | ), 224 | noop(), 225 | ) 226 | ; 227 | } 228 | ``` 229 | 230 | 231 | ### try 232 | The standard try/catch as an expression. The error expression is scoped and gets access to a `--error` value. 233 | 234 | ```css 235 | form#my-form { 236 | --cssx-on-submit: 237 | prevent-default() 238 | add-class(form, 'submitting') 239 | try( 240 | do( 241 | request('/your-api/some-api', 'POST'), 242 | add-class(form, 'submitted') 243 | ), 244 | js-eval(string('alert("', get-var(--error), '")')) 245 | ) 246 | remove-class(form, 'submitting') 247 | ; 248 | } 249 | ``` 250 | 251 | ### let 252 | Create a binding for use inside a scoped expression. 253 | 254 | `--random` is only available within the let binding 255 | ```css 256 | #my-element { 257 | --cssx-on-mount: 258 | let(--random, js-eval('Math.random()'), 259 | js-eval('alert("', get-var(--random),'")') 260 | ) 261 | ; 262 | } 263 | ``` 264 | 265 | 266 | === 267 | 268 | 269 | ## Others 270 | 271 | ### js-eval 272 | Evaluate any js expression. Easy escape hatch directly to hell. 273 | 274 | ```typescript 275 | function js-eval(string): string 276 | ``` 277 | 278 | ### request 279 | > WIP Docs 280 | 281 | ### delay 282 | Wait a bit. 283 | 284 | ```typescript 285 | function delay(duration): void 286 | ``` 287 | 288 | Examples for input - 289 | - `delay(100)`: wait for 100 milliseconds 290 | - `delay(100ms)`: wait for 100 milliseconds 291 | - `delay(5s)`: wait for 5 seconds 292 | - `delay(0.5s)`: wait for 500 milliseconds 293 | 294 | 295 | === 296 | 297 | 298 | ## DOM 299 | 300 | ### load-cssx 301 | Load more of this abomination into your page 302 | 303 | 304 | ### set-attr 305 | 306 | 307 | ### attr 308 | 309 | 310 | ### add-children 311 | 312 | 313 | ### remove-element 314 | 315 | 316 | ### prevent-default 317 | 318 | 319 | ### call-method 320 | 321 | 322 | -------------------------------------------------------------------------------- /docs/api/hooks.md: -------------------------------------------------------------------------------- 1 | # Element hooks 2 | 3 | ```css 4 | #my-element { 5 | --cssx-on-mount: /* code */; 6 | --cssx-on-update: /* code */; 7 | --cssx-on-click: /* code */; 8 | } 9 | ``` 10 | 11 | 12 | ## Mount 13 | 14 | Mount is mount. Remember when react used to call it that? Yeah, fun times. 15 | It'll allow you to run code as soon as the element in the dom. 16 | 17 | ```css 18 | #my-element { 19 | --cssx-on-mount: add-class('animate'); 20 | } 21 | ``` 22 | 23 | 24 | ## Update 25 | 26 | The way you manage state in css-everything is as css custom properties. 27 | Sometimes you may need to react to those changes. That's where the update hook comes in. 28 | The update hook gets called every time a css property on the element is updated via the `update` function. 29 | 30 | NOTE: Update hook is only called for the element that gets updated, not it's children. 31 | 32 | ```css 33 | #my-element { 34 | background-color: Salmon; 35 | color: PowderBlue; 36 | 37 | --my-state: false; 38 | --cssx-on-update: if(get-var(--my-state), add-class('some-state'), remove-class('some-state')); 39 | 40 | --cssx-children: button#my-btn; 41 | } 42 | 43 | #my-element.some-state { 44 | background-color: PapayaWhip; 45 | color: SeaShell; 46 | } 47 | 48 | /* #my-btn has access to --is-visible because it is #my-element's child */ 49 | #my-btn { 50 | --cssx-on-click: update(my-element, --is-visible, if(get-var(--my-state), true, false)); 51 | } 52 | ``` 53 | 54 | 55 | ## Events 56 | 57 | Other than the above 2 events, the rest are just standard browser events. 58 | 59 | Only the following are supported as of right now as my fingers are typing this out - 60 | 61 | - `--cssx-on-click` 62 | - `--cssx-on-focus` 63 | - `--cssx-on-blur` 64 | - `--cssx-on-submit` 65 | 66 | Adding support for most other events is pretty trivial. I'm just kinda lazy. 67 | 68 | 69 | -------------------------------------------------------------------------------- /docs/api/properties.md: -------------------------------------------------------------------------------- 1 | # Properties 2 | 3 | ## Children 4 | 5 | Property: `--cssx-children` 6 | 7 | The way you build out the dom of your application is using the `--cssx-children` property. 8 | 9 | ```css 10 | #my-element { 11 | --cssx-children: 12 | div#my-element.some-class 13 | input#input-el[type=email][placeholder="Some placeholder"] 14 | instance(div#my-component, map(--css-prop: "some value")) 15 | ; 16 | } 17 | ``` 18 | 19 | * `div#my-element.some-class`: div with id = `my-element` and class = `some-class` 20 | * `input#input-el[type=email][placeholder="Some placeholder"]`: input element with input-el id and, `type` and `placeholder` attributes set. 21 | * `instance(div#my-component, map(--css-prop: "some value", --other: "Yo"))`: Creates an instance of `my-component` with the `--css-prop` and `--other` css custom properties set. 22 | 23 | 24 | ## Text 25 | 26 | Property: `--cssx-text` 27 | 28 | To set the text content of an element, you can use the `--cssx-text` property. 29 | 30 | NOTE: As of writing this, `--cssx-text` is only set on mount and is not updated. Will fix that whenever, probably. 31 | 32 | ```css 33 | #my-element { 34 | --cssx-text: "Hey. What's up? Dom element here."; 35 | } 36 | ``` 37 | 38 | 39 | ## Raw HTML 40 | 41 | Property: `--cssx-disgustingly-set-innerhtml` 42 | 43 | You can set arbitrary html in your content. Any html set directly will not be managed by css-everything. 44 | 45 | NOTE: Try to avoid doing this. Please refer to [security.md](../security.md) for more information. 46 | 47 | ```css 48 | #my-element { 49 | --cssx-disgustingly-set-innerhtml: ""; 50 | } 51 | ``` 52 | 53 | 54 | ## Others 55 | 56 | Every other css property on your elements is a piece of state that every one of your children and grand-children can inherit it. Although, the `--cssx-on-update` hook will only be called for the element that was updated. 57 | 58 | -------------------------------------------------------------------------------- /docs/best-practices.md: -------------------------------------------------------------------------------- 1 | # LOL 2 | -------------------------------------------------------------------------------- /docs/how-it-works.md: -------------------------------------------------------------------------------- 1 | # How does it work? 2 | Who knows really? It's just magic for the most part. 3 | 4 | === 5 | 6 | ## Creating the dom tree 7 | 8 | Everything starts with the `body` element (by default). 9 | 10 | In your css, you can use `body` or `html` or `:root`. As long as your root (body by default) inherits that property, it's all good. 11 | ```css 12 | :root { 13 | --cssx-children: div#my-element; 14 | } 15 | ``` 16 | 17 | This will create a div inside `body` with the `id` (and `data-element` attribute) of `my-element`. 18 | 19 | 20 | Let's go deeper... 21 | 22 | ```css 23 | :root { 24 | --cssx-children: div#my-element; 25 | } 26 | 27 | #my-element { 28 | --cssx-children: header#div-a main#div-b; 29 | } 30 | ``` 31 | 32 | Now `my-element`, gets 2 children. You can probably figure out what those would look like. 33 | 34 | You may have already noticed a problem here. If you don't override the --cssx-children property, wouldn't all children of body get access to that? 35 | 36 | Well yeah, which is why, we have a `.cssx-layer` element between the parent and children. This element wraps all children and makes it so all the cssx property are unset. This can occasionally make styling a bit difficult but that's a YOU problem for trying to use this. 37 | 38 | 39 | ## Instances 40 | Instances are sort of like components. You can instantiate elements and provide them some custom properties. 41 | 42 | NOTE: Instances get unique ids so instances and children of instances cannot not use the id selector for the definition. 43 | 44 | ```css 45 | #my-element { 46 | --cssx-children: 47 | instance(div#user-card, map(--name: "Sugom Afart", --age: 20)) 48 | instance(div#user-card, map(--name: "Leeki Bahol", --age: 69)) 49 | instance(div#user-card, map(--name: "Yamam Aho", --age: 40)) 50 | ; 51 | } 52 | 53 | [data-instance=user-card] { 54 | --name: "default name"; 55 | --age: 0; 56 | 57 | --cssx-children: div#name div#age; 58 | } 59 | 60 | [data-element=name]::after { 61 | /* Using the ::after element to set content via css */ 62 | content: "Name: " var(--name); 63 | } 64 | [data-element=age] { 65 | /* Using the --cssx-text property because css doesn't like numbers in `content` */ 66 | --cssx-text: string("Age: ", get-var(--age)); 67 | } 68 | ``` 69 | 70 | 71 | 72 | ## Custom functions 73 | 74 | This is by far the most "fun" aspect of this project. Take a look at the docs for [call](./api/functions.md#call) for the api and examples. 75 | 76 | ```css 77 | #my-element { 78 | --factorial: func(--n: number) 79 | if(lte(get-var(--n), 1), 1, 80 | calc( 81 | get-var(--n) 82 | * call(--factorial, map(--n: calc(get-var(--n) - 1))) 83 | )); 84 | 85 | --cssx-on-mount: js-eval(string( 86 | 'console.log("', 87 | call(--factorial, map(--n: 5)), 88 | '")' 89 | )); 90 | } 91 | ``` 92 | 93 | > NOTE: `func` is noop and just exists for documentation. You can also do `func(--a: string, --b: number)` and it'll be valid syntax but ignored at evaluation. So basically, typescript. 94 | 95 | > NOTE: If you come at me with how using `js-eval` is cheating, I won't be responsible for your injuries. JS-in-CSS is the future. 96 | 97 | The way this works is that it creates a new dom element with `display: none` inside the caller (`#my-element`), which then becomes the scope for the function. 98 | Whatever arguments are passed to `call` will be added as css properties to this dom element. 99 | Then the expressions inside the function is evaluated within the context of that element. 100 | 101 | This means that with `call(--factorial, map(--n: 5))` the dom tree would look something like this. 102 | 103 | ```html 104 |
105 |
106 | 107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 | ``` 119 | 120 | This is the call stack. This is deleted as soon as the required computation is completed. (tail-call optimization, maybe? Hahahahaha. Kill me.) 121 | 122 | > PRO TIP 1: If you want the tree to persist even after the function is evaluated (for debugging), add the `data-debug-stack` attribute to the caller element 123 | 124 | > PRO TIP 2: You could style these nodes to have this tree show up in the ui and use the `--cssx-text` property to display the arguments for each recursive function call 125 | 126 | > PRO TIP 3: If you're running into infinite loops, good luck. Also, you can add `delay(1s)` at the start of the function to slow things down to debug. 127 | -------------------------------------------------------------------------------- /docs/recipies.md: -------------------------------------------------------------------------------- 1 | # Recipies 2 | 3 | Here's some stuff you can do with this 4 | 5 | 6 | ## Fishy HTML 7 | 8 | You can set arbitrary html in your content. Any html set directly will not be managed by css-everything. 9 | 10 | NOTE: Try to avoid doing this. Please refer to [./security.md](./security.md) for more information. 11 | 12 | ```css 13 | #my-element { 14 | --cssx-disgustingly-set-innerhtml: ""; 15 | } 16 | ``` 17 | 18 | 19 | ## Spicy forms 20 | 21 | ```css 22 | #wrapper { 23 | --cssx-children: form#my-form; 24 | } 25 | 26 | #my-form { 27 | --cssx-on-submit: 28 | prevent-default() 29 | add-class('loading') 30 | request('/some-api', 'PUT') 31 | remove-class('loading') 32 | ; 33 | 34 | --cssx-children: 35 | input#email-input[type="email"][placeholder="Email"] 36 | input#password-input[type="password"][placeholder="Password"] 37 | input#submit-btn[type="submit"] 38 | ; 39 | } 40 | 41 | #submit-btn { 42 | --cssx-text: "Log in"; 43 | } 44 | ``` 45 | 46 | 47 | ## Infinite soup 48 | 49 | ```css 50 | #my-element { 51 | --cssx-on-mount: update(--random, '0'); 52 | --cssx-on-update: delay(500ms) update(--random, js-eval('Math.random()')); 53 | } 54 | 55 | #my-element::after { 56 | content: var(--random); 57 | } 58 | ``` 59 | 60 | 61 | ## Re-usable plates / Components / Instances 62 | 63 | ```css 64 | #my-element { 65 | --cssx-on-click: 66 | add-children( 67 | my-element, 68 | instance(#my-component, map(--text: "A new item")) 69 | ) 70 | ; 71 | 72 | --cssx-children: 73 | instance(#my-component, map(--text: "First item")) 74 | instance(#my-component, map(--text: "Second item")) 75 | instance(#my-component, map(--text: "Third item")) 76 | ; 77 | } 78 | 79 | /* Use data-instance for selecting instances and data-element for selecting children of instances */ 80 | [data-instance=my-component] { 81 | --text: "Some default text"; 82 | 83 | --cssx-children: div#child; 84 | } 85 | 86 | [data-instance=my-component]::before { 87 | content: var(--text); 88 | } 89 | 90 | [data-element=child] { 91 | color: 1px solid BlanchedAlmond; 92 | background-color: DarkGoldenRod; 93 | 94 | --cssx-text: "Some text"; 95 | } 96 | ``` 97 | 98 | 99 | ## Debugging call stack 100 | 101 | > WIP docs 102 | 103 | 104 | -------------------------------------------------------------------------------- /docs/security.md: -------------------------------------------------------------------------------- 1 | # LOL 2 | -------------------------------------------------------------------------------- /esbuild.js: -------------------------------------------------------------------------------- 1 | const esbuild = require('esbuild') 2 | 3 | esbuild 4 | .build({ 5 | entryPoints: ['src/renderer.ts'], 6 | outfile: 'dist/renderer/index.js', 7 | bundle: true, 8 | sourcemap: true, 9 | minify: true, 10 | splitting: false, 11 | format: 'iife', 12 | target: ['es2015'], 13 | }) 14 | .catch(e => (console.error(e), process.exit(1))) 15 | -------------------------------------------------------------------------------- /examples/calculator/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Calculator 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /examples/calculator/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --cssx-children: main#container; 3 | } 4 | 5 | html, body { 6 | margin: 0; 7 | padding: 0; 8 | background-color: #e2e8f0; 9 | font-size: 16px; 10 | } 11 | body * { 12 | box-sizing: border-box; 13 | font-family: Courier, monospace; 14 | } 15 | 16 | #container { 17 | --num1: ''; 18 | --num2: ''; 19 | --operation: ''; 20 | 21 | max-width: 400px; 22 | margin: 2rem auto; 23 | padding: 1rem; 24 | background-color: #020617; 25 | color: #e2e8f0; 26 | 27 | --cssx-children: #display hr#sep0 #buttons; 28 | } 29 | 30 | #display { 31 | padding: 0 1rem; 32 | line-height: 1.7em; 33 | font-size: 2rem; 34 | height: 3rem; 35 | background-color: #0f172a; 36 | text-align: right; 37 | } 38 | #display::after { 39 | content: var(--num1) var(--operation) var(--num2); 40 | } 41 | 42 | #buttons { 43 | --cssx-children: 44 | h(div#toprow.horizontal, map(), seq( 45 | button#btn-clear, 46 | button#btn-run, 47 | )) 48 | div#buttons-numbers 49 | div#buttons-operators.horizontal 50 | ; 51 | } 52 | #buttons > * { 53 | display: flex; 54 | flex-direction: column; 55 | gap: 20px; 56 | } 57 | 58 | #buttons-operators { 59 | --cssx-children: 60 | instance(button#btn-op, map(--op: "+")) 61 | instance(button#btn-op, map(--op: "-")) 62 | instance(button#btn-op, map(--op: "*")) 63 | instance(button#btn-op, map(--op: "/")) 64 | instance(button#btn-op, map(--op: "!")) 65 | ; 66 | } 67 | 68 | #buttons-numbers { 69 | --cssx-children: 70 | instance(button#btn-num, map(--n: "9")) 71 | instance(button#btn-num, map(--n: "8")) 72 | instance(button#btn-num, map(--n: "7")) 73 | instance(button#btn-num, map(--n: "6")) 74 | instance(button#btn-num, map(--n: "5")) 75 | instance(button#btn-num, map(--n: "4")) 76 | instance(button#btn-num, map(--n: "3")) 77 | instance(button#btn-num, map(--n: "2")) 78 | instance(button#btn-num, map(--n: "1")) 79 | instance(button#btn-num, map(--n: "0")) 80 | ; 81 | } 82 | #buttons-numbers > * { 83 | display: grid; 84 | grid-template-columns: repeat(3, 1fr); 85 | gap: 2px; 86 | } 87 | 88 | [data-instance=btn-num]::after { content: var(--n); } 89 | [data-instance=btn-num] { 90 | width: 100%; 91 | --cssx-on-click: 92 | if(get-var(--operation), 93 | if(equals(get-var(--operation), '!'), 94 | '', 95 | update('container', --num2, string(get-var(--num2), get-var(--n)))), 96 | update('container', --num1, string(get-var(--num1), get-var(--n))), 97 | ); 98 | } 99 | 100 | [data-instance=btn-op]::after { content: var(--op); } 101 | [data-instance=btn-op] { 102 | width: 100%; 103 | --cssx-on-click: update('container', --operation, get-var(--op)); 104 | } 105 | 106 | #btn-run::after { content: '='; } 107 | #btn-run { 108 | --factorial: func(--n: number) 109 | if(lte(get-var(--n), 1), 1, 110 | calc( 111 | get-var(--n) 112 | * call(--factorial, map(--n: calc(get-var(--n) - 1))) 113 | )); 114 | 115 | --cssx-on-click: 116 | update('container', --num1, 117 | if(equals(get-var(--operation), '!'), 118 | call(--factorial, map(--n: get-var(--num1))), 119 | if(get-var(--num2), 120 | js-eval(string(get-var(--num1), get-var(--operation), get-var(--num2))), 121 | get-var(--num1) 122 | ) 123 | ) 124 | ) 125 | update('container', --operation, '') 126 | update('container', --num2, ''); 127 | } 128 | 129 | #btn-clear::after { content: 'clear'; } 130 | #btn-clear { 131 | --cssx-on-click: 132 | update('container', --num1, '') 133 | update('container', --operation, '') 134 | update('container', --num2, ''); 135 | } 136 | 137 | button { 138 | width: 100%; 139 | padding: 0.5rem 0; 140 | border: none; 141 | background-color: #475569; 142 | color: white; 143 | font-weight: bold; 144 | font-size: 1.3rem; 145 | cursor: pointer; 146 | } 147 | button:hover { background-color: #334155; } 148 | button:active { background-color: #64748b; } 149 | 150 | body .horizontal > * { 151 | width: 100%; 152 | display: flex; 153 | justify-content: space-between; 154 | gap: 2px; 155 | } 156 | -------------------------------------------------------------------------------- /examples/clock/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Clock 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /examples/clock/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --color-gray: #cccccc; 3 | --color-accent: #5180e9; 4 | 5 | font-size: 16px; 6 | font-family: sans-serif; 7 | color: #555; 8 | 9 | --cssx-children: 10 | div#digital 11 | h(div#analog, map(), seq( 12 | div#seconds.analog-clock-hand, 13 | div#minutes.analog-clock-hand, 14 | div#hours.analog-clock-hand 15 | )); 16 | } 17 | 18 | html, body { 19 | margin: 0; 20 | padding: 0; 21 | background-color: #0f172a; 22 | color: #e2e8f0; 23 | } 24 | 25 | body * { box-sizing: border-box; } 26 | 27 | #digital { 28 | text-align: center; 29 | padding: 1rem; 30 | font-size: 1.5rem; 31 | 32 | --text: "hello"; 33 | 34 | --js-expr: 35 | new Intl.DateTimeFormat('en-US', { 36 | hour: 'numeric', 37 | minute: 'numeric', 38 | second: 'numeric', 39 | }).format(new Date()); 40 | --get-date: js-eval(get-var(--js-expr)); 41 | 42 | --cssx-on-mount: update(--text, call(--get-date)); 43 | --cssx-on-update: delay(1s) update(--text, call(--get-date)); 44 | } 45 | #digital::after { content: var(--text); } 46 | 47 | #analog { 48 | background-color: #1e293b; 49 | width: 200px; 50 | height: 200px; 51 | margin: 1rem auto; 52 | border-radius: 50%; 53 | position: relative; 54 | 55 | --date: ""; 56 | --get-date: js-eval("new Date()"); 57 | 58 | --cssx-on-mount: update(--date, call(--get-date)); 59 | --cssx-on-update: 60 | update('[data-element=seconds]', --angle, calc(360 * js-eval("new Date().getSeconds()")/60 - 90)) 61 | update('[data-element=minutes]', --angle, calc(360 * js-eval("new Date().getMinutes()")/60 - 90)) 62 | update('[data-element=hours]', --angle, calc( 63 | 360 * js-eval("new Date().getHours() % 12")/12 - 90 64 | + (30 * js-eval("new Date().getMinutes()")/60) 65 | )) 66 | delay(1s) 67 | update(--date, call(--get-date)); 68 | } 69 | 70 | [data-element=seconds].analog-clock-hand { 71 | --color: #cbd5e1; 72 | --size: 70px; 73 | } 74 | [data-element=minutes].analog-clock-hand { 75 | --color: #991b1b; 76 | --size: 60px; 77 | } 78 | [data-element=hours].analog-clock-hand { 79 | --color: #4f46e5; 80 | --size: 40px; 81 | height: 4px; 82 | } 83 | 84 | .analog-clock-hand { 85 | --angle: 0; 86 | --color: black; 87 | --size: 80px; 88 | 89 | width: var(--size); 90 | height: 2px; 91 | position: absolute; 92 | top: 50%; 93 | left: 50%; 94 | transform-origin: 0% 50%; 95 | border-radius: 5px; 96 | transform: rotate(0deg); 97 | 98 | --get-transform: string('rotate(', get-var(--angle), 'deg)'); 99 | 100 | --cssx-on-mount: 101 | update(background-color, get-var(--color)) 102 | update(transform, call(--get-transform)) 103 | ; 104 | --cssx-on-update: update(transform, call(--get-transform)); 105 | } 106 | 107 | -------------------------------------------------------------------------------- /examples/form/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Register to this awesome website 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/form/signup.css: -------------------------------------------------------------------------------- 1 | #signup-page-content { 2 | border: 1px solid #888; 3 | padding: 1rem; 4 | max-width: 700px; 5 | margin: 1rem auto; 6 | 7 | --count: '0'; 8 | 9 | --cssx-disgustingly-set-innerhtml: 10 | "

Sign-Up

"; 11 | --cssx-children: form#form; 12 | } 13 | 14 | .form-title { 15 | font-size: 2rem; 16 | padding: 0; 17 | border-bottom: 1px solid gray; 18 | font-weight: normal; 19 | color: gray; 20 | } 21 | .form-title b { 22 | font-weight: bold; 23 | color: black; 24 | } 25 | 26 | #form { 27 | display: block; 28 | 29 | --cssx-on-submit: 30 | prevent-default() 31 | add-class(form, 'submitting') 32 | try( 33 | do( 34 | request('/examples'), 35 | add-class(form, 'submitted') 36 | ), 37 | js-eval(string('alert("', get-var(--error), '")')) 38 | ) 39 | remove-class(form, 'submitting') 40 | ; 41 | 42 | --cssx-children: 43 | input#input-email 44 | input#input-password 45 | #actions 46 | ; 47 | } 48 | #form.submitted #message::after { 49 | display: block; 50 | content: 'Form submitted successfully'; 51 | } 52 | #form.submitting #submit-btn { 53 | pointer-events: none; 54 | opacity: 0.5; 55 | } 56 | 57 | #form input { 58 | display: block; 59 | width: 100%; 60 | padding: 0.4rem 0.8rem; 61 | margin-top: 1rem; 62 | } 63 | 64 | #input-email { 65 | --cssx-on-mount: set-attr('type', 'email') set-attr('name', 'email') 66 | set-attr('required', 'true') 67 | set-attr('placeholder', 'Email. Eg:- mail@postbox.com'); 68 | } 69 | 70 | #input-password { 71 | --cssx-on-mount: set-attr('type', 'password') set-attr('name', 'password') 72 | set-attr('required', 'true') 73 | set-attr( 74 | 'placeholder', 75 | 'Password. Eg:- password, password1, password2, password123' 76 | ); 77 | } 78 | 79 | #actions { 80 | text-align: right; 81 | padding-top: 1rem; 82 | --cssx-children: button#submit-btn; 83 | } 84 | 85 | #submit-btn { 86 | padding: 0.4rem 0.7rem; 87 | --cssx-on-mount: set-attr('type', 'submit'); 88 | } 89 | #submit-btn::after { 90 | content: 'Sign-Up'; 91 | } 92 | -------------------------------------------------------------------------------- /examples/form/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | --cssx-children: button#signup-btn #signup-page; 3 | } 4 | body * { box-sizing: border-box; } 5 | 6 | #signup-btn { 7 | display: inline-block; 8 | background: #5180e9; 9 | color: #fff; 10 | border: none; 11 | outline: none; 12 | padding: 0.5rem 1rem; 13 | cursor: pointer; 14 | 15 | --cssx-on-click: 16 | add-class(signup-page, 'loading') 17 | add-class(signup-btn, 'loading') 18 | delay(0.5s) 19 | load-cssx(signup-page-content, './signup.css') 20 | remove-class(signup-page, 'loading') 21 | remove-class(signup-btn, 'loading'); 22 | } 23 | #signup-btn::after { 24 | content: 'Register now to start your free trial for $99'; 25 | } 26 | #signup-btn:hover { 27 | opacity: 0.8; 28 | } 29 | #signup-btn.loading { 30 | pointer-events: none; 31 | opacity: 0.4; 32 | } 33 | 34 | #signup-page { 35 | --cssx-children: div#signup-page-content; 36 | } 37 | #signup-page.loading::after { 38 | content: 'Loading...'; 39 | } 40 | -------------------------------------------------------------------------------- /examples/todo-list/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Task destroyer 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /examples/todo-list/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --color-gray: #cccccc; 3 | --color-accent: #5180e9; 4 | 5 | font-size: 16px; 6 | font-family: sans-serif; 7 | color: #555; 8 | 9 | --cssx-children: main#container; 10 | } 11 | 12 | body * { box-sizing: border-box; } 13 | 14 | #container { 15 | max-width: 600px; 16 | margin: 2rem auto; 17 | border: 1px solid var(--color-gray); 18 | border-radius: 5px; 19 | overflow: hidden; 20 | 21 | --cssx-children: form#task-input-form #task-list; 22 | } 23 | 24 | #task-input-form { 25 | display: flex; 26 | width: 100%; 27 | 28 | /* prettier-ignore */ 29 | --cssx-on-submit: 30 | prevent-default() 31 | add-children( 32 | task-list, 33 | instance(div#task-item, map(--text: attr(text-input, 'value'))) 34 | ) 35 | set-attr(text-input, value, '') 36 | ; 37 | 38 | /* prettier-ignore */ 39 | --cssx-on-mount: 40 | add-children( 41 | task-list, 42 | instance(div#task-item, map(--text: "Buy lemons")) 43 | ) 44 | add-children( 45 | task-list, 46 | instance(div#task-item, map(--text: "Make lemonaide")) 47 | ) 48 | add-children( 49 | task-list, 50 | instance(div#task-item, map(--text: "Kill all the non-believers")) 51 | ) 52 | ; 53 | 54 | /* prettier-ignore */ 55 | --cssx-children: 56 | input#text-input[placeholder="Eg: Buy Milk"] 57 | button#create-task-btn[type=submit] 58 | ; 59 | } 60 | 61 | #text-input { 62 | display: block; 63 | width: 100%; 64 | padding: 0.7rem 1rem; 65 | font-size: 1rem; 66 | border: none; 67 | border-bottom: 1px solid var(--color-gray); 68 | } 69 | 70 | #create-task-btn { 71 | padding: 0 2rem; 72 | background-color: var(--color-accent); 73 | color: white; 74 | font-weight: bold; 75 | border: none; 76 | font-size: 1.2rem; 77 | 78 | --cssx-text: '+'; 79 | } 80 | 81 | [data-instance=task-item] { 82 | --text: "default text"; 83 | --done: "false"; 84 | --is-editing: "false"; 85 | 86 | padding: 1rem; 87 | display: flex; 88 | width: 100%; 89 | align-items: center; 90 | 91 | --cssx-on-mount: update(--task-item-id, attr(data-element)); 92 | 93 | --cssx-on-update: 94 | update( 95 | ':scope [data-element=edit-task-form]', 96 | display, 97 | if(get-var(--is-editing), 'block', 'none') 98 | ) 99 | update( 100 | ':scope [data-element=task-text]', 101 | display, 102 | if(get-var(--is-editing), 'none', 'block') 103 | ) 104 | if(get-var(--is-editing), 105 | call-method(':scope [data-element=edit-task-input]', focus), 106 | "") 107 | ; 108 | 109 | --cssx-children: 110 | div#checkbox 111 | div#task-text 112 | form#edit-task-form 113 | button#delete-task 114 | ; 115 | } 116 | 117 | [data-instance=task-item]:not(:first-child) { 118 | content: ""; 119 | border-top: 1px solid var(--color-gray); 120 | } 121 | 122 | [data-element=task-text] { 123 | padding: .2rem .8rem; 124 | flex: 2; 125 | 126 | --cssx-on-click: update(get-var(--task-item-id), --is-editing, "true"); 127 | } 128 | [data-element=task-text]::after { 129 | content: var(--text); 130 | } 131 | 132 | [data-element=edit-task-form] { 133 | display: none; 134 | width: 100%; 135 | padding: 0 .5rem; 136 | 137 | --cssx-children: input#edit-task-input; 138 | --cssx-on-submit: 139 | prevent-default() 140 | update( 141 | get-var(--task-item-id), 142 | --text, 143 | attr(':scope [data-element=edit-task-input]', value) 144 | ) 145 | update(get-var(--task-item-id), --is-editing, "false") 146 | ; 147 | } 148 | [data-element=edit-task-input] { 149 | display: block; 150 | width: 100%; 151 | border: none; 152 | border-bottom: 1px solid gray; 153 | font-size: 1rem; 154 | padding: .2rem .3rem; 155 | 156 | --cssx-on-focus: set-attr(value, get-var(--text)); 157 | --cssx-on-blur: update(get-var(--task-item-id), --is-editing, "false"); 158 | } 159 | [data-element=edit-task-input]:focus { 160 | outline: 1px solid #aaa; 161 | } 162 | 163 | [data-element=checkbox] { 164 | --checked: false; 165 | 166 | width: 18px; 167 | height: 18px; 168 | border: 2px solid gray; 169 | background-color: transparent; 170 | cursor: pointer; 171 | 172 | --cssx-on-click: update(--checked, if(get-var(--checked), false, true)); 173 | 174 | --cssx-on-update: 175 | update(get-var(--task-item-id), --done, get-var(--checked)) 176 | update(background-color, if(get-var(--checked), get-var(--color-accent), transparent)) 177 | ; 178 | } 179 | 180 | [data-element=delete-task] { 181 | --cssx-text: 'Delete'; 182 | --cssx-on-click: remove-element(get-var(--task-item-id)); 183 | } 184 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'jsdom', 5 | transform: { 6 | "^.+\\.spec.ts?$": [ 7 | "ts-jest", 8 | { 9 | diagnostics: { 10 | exclude: ['**'], 11 | }, 12 | }, 13 | ], 14 | }, 15 | transformIgnorePatterns: ["/node_modules/"], 16 | }; 17 | -------------------------------------------------------------------------------- /media/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phenax/css-everything/ee25ba75390b8437f3674b85a80dba2c8ddf19b2/media/banner.png -------------------------------------------------------------------------------- /media/banner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 39 | 41 | 45 | 52 | [css-everything] 63 | 64 | 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@css-everything/render", 3 | "version": "0.1.1", 4 | "main": "src/index.ts", 5 | "repository": "https://github.com/phenax/css-everything", 6 | "author": "Akshay Nair ", 7 | "license": "MIT", 8 | "files": [ 9 | "dist/", 10 | "src/", 11 | "README.md", 12 | "LICENSE" 13 | ], 14 | "scripts": { 15 | "build": "tsc && node esbuild.js", 16 | "serve": "serve -p 3000 .", 17 | "format": "prettier --write './{src,tests}/**/*.{ts,html,css}'", 18 | "lint": "eslint ./src", 19 | "fix": "yarn lint --fix && yarn format", 20 | "pub:patch": "yarn lint && yarn build && yarn publish --access=public --patch", 21 | "test": "jest" 22 | }, 23 | "devDependencies": { 24 | "@testing-library/dom": "^9.3.1", 25 | "@testing-library/jest-dom": "^5.17.0", 26 | "@types/jest": "^29.5.3", 27 | "@typescript-eslint/eslint-plugin": "^6.3.0", 28 | "@typescript-eslint/parser": "^6.3.0", 29 | "esbuild": "^0.18.17", 30 | "eslint": "^8.47.0", 31 | "eslint-config-prettier": "^9.0.0", 32 | "eslint-plugin-prettier": "^5.0.0", 33 | "jest": "^29.6.2", 34 | "jest-environment-jsdom": "^29.6.2", 35 | "prettier": "^3.0.1", 36 | "serve": "^14.2.0", 37 | "ts-jest": "^29.1.1", 38 | "ts-node": "^10.9.1", 39 | "typescript": "^5.1.6" 40 | }, 41 | "dependencies": {} 42 | } 43 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | with (import { }); 2 | mkShell { 3 | buildInputs = [ 4 | nodejs_21 5 | nodePackages.typescript 6 | nodePackages.prettier 7 | nodePackages.eslint 8 | ]; 9 | } 10 | -------------------------------------------------------------------------------- /src/declarations.ts: -------------------------------------------------------------------------------- 1 | import { EvalActions, EvalValue, evalExpr } from './eval' 2 | import { Expr, Selector, SelectorComp, parseDeclarations } from './parser' 3 | import { match, matchString } from './utils/adt' 4 | 5 | export interface Declaration { 6 | selector: Selector 7 | properties: Map 8 | children: Array 9 | isInstance: boolean 10 | } 11 | 12 | const instanceCountMap = new Map() 13 | const getUniqueInstanceId = (id: string) => { 14 | const instanceCount = instanceCountMap.get(id) ?? 0 15 | instanceCountMap.set(id, instanceCount + 1) 16 | return `${id}--index-${instanceCount}` 17 | } 18 | 19 | export const toDeclaration = 20 | (actions: EvalActions) => 21 | async (expr: Expr): Promise => { 22 | let selector: Selector | undefined 23 | const properties: Map = new Map() 24 | const children: Array = [] 25 | let isInstance = false 26 | 27 | await match(expr, { 28 | Selector: async sel => { 29 | selector = sel 30 | }, 31 | Call: async ({ name, args }) => { 32 | return matchString(name, { 33 | h: async () => { 34 | const [sel, map, childreExpr] = args 35 | 36 | // Selector 37 | match(sel, { 38 | Selector: sel => { 39 | selector = sel 40 | }, 41 | _: _ => {}, 42 | }) 43 | 44 | const props = await evalExpr(map, actions) 45 | match(props, { 46 | Map: props => { 47 | for (const [key, value] of Object.entries(props)) { 48 | properties.set(key, value) 49 | } 50 | }, 51 | _: _ => {}, 52 | }) 53 | 54 | const childrenExprs = await match< 55 | Promise>, 56 | EvalValue 57 | >(await evalExpr(childreExpr, actions), { 58 | Lazy: async exprs => 59 | Promise.all(exprs.map(toDeclaration(actions))), 60 | _: async _ => [], 61 | }) 62 | 63 | children.push( 64 | ...(childrenExprs.filter(Boolean) as Array), 65 | ) 66 | }, 67 | instance: async () => { 68 | isInstance = true 69 | const [sel, map] = args 70 | 71 | // Selector 72 | match(sel, { 73 | Selector: sel => { 74 | selector = sel 75 | }, 76 | _: _ => {}, 77 | }) 78 | 79 | const props = await evalExpr(map, actions) 80 | match(props, { 81 | Map: props => { 82 | for (const [key, value] of Object.entries(props)) { 83 | properties.set(key, value) 84 | } 85 | }, 86 | _: _ => {}, 87 | }) 88 | }, 89 | _: async () => { 90 | throw new Error(`weird function in cssx-chi9ldren: ${name}`) 91 | }, 92 | }) 93 | }, 94 | _: async () => {}, 95 | }) 96 | 97 | if (!selector) return undefined 98 | 99 | if (isInstance) { 100 | const baseId = selector.id 101 | selector.id = getUniqueInstanceId(selector.id) 102 | selector.selectors.push(SelectorComp.Attr(['data-instance', baseId])) 103 | } 104 | 105 | return { selector, properties, children, isInstance } 106 | } 107 | 108 | export const expressionsToDeclrs = async ( 109 | exprs: Array, 110 | actions: EvalActions, 111 | ): Promise> => { 112 | const declrs = await Promise.all(exprs.map(toDeclaration(actions))) 113 | return declrs.filter(declr => !!declr) as Array 114 | } 115 | 116 | export const extractDeclaration = async ( 117 | input: string, 118 | actions: EvalActions, 119 | ): Promise> => { 120 | const exprs = parseDeclarations(input) 121 | return expressionsToDeclrs(exprs, actions) 122 | } 123 | -------------------------------------------------------------------------------- /src/eval.ts: -------------------------------------------------------------------------------- 1 | import { CSSUnit, Expr, parse, parseExpr } from './parser' 2 | import { Enum, constructors, match, matchString } from './utils/adt' 3 | 4 | export interface EvalActions { 5 | addClass(id: string, classes: string): Promise 6 | removeClass(id: string, classes: string): Promise 7 | delay(num: number): Promise 8 | jsEval(js: string): Promise 9 | loadCssx(id: string, url: string): Promise 10 | getVariable(name: string): Promise 11 | updateVariable( 12 | id: string | undefined, 13 | varName: string, 14 | value: string, 15 | ): Promise 16 | getAttribute( 17 | id: string | undefined, 18 | name: string, 19 | ): Promise 20 | setAttribute( 21 | id: string | undefined, 22 | name: string, 23 | value: string, 24 | ): Promise 25 | withEvent(fn: (e: any) => void): Promise 26 | getFormData(): Promise 27 | sendRequest(_: { 28 | method: string 29 | url: string 30 | data: FormData | undefined 31 | }): Promise 32 | addChildren(id: string, children: Expr[]): Promise 33 | removeElement(id: string | undefined): Promise 34 | callMethod( 35 | id: string | undefined, 36 | method: string, 37 | args: (string | undefined)[], 38 | ): Promise 39 | evaluateInScope( 40 | exprs: Expr[], 41 | properties: Record, 42 | ): Promise 43 | // calculate ?? 44 | } 45 | 46 | export type EvalValue = Enum<{ 47 | String: string 48 | Number: number 49 | Boolean: boolean 50 | Lazy: Array 51 | Void: never 52 | VarIdentifier: string 53 | Map: { [key in string]: EvalValue } 54 | Value: any 55 | }> 56 | export const EvalValue = constructors() 57 | 58 | export const evalExprAsString = async ( 59 | expr: Expr, 60 | actions: EvalActions, 61 | ): Promise => { 62 | const evalVal = await evalExpr(expr, actions) 63 | return evalValueToString(evalVal) 64 | } 65 | 66 | export const evalExpr = async ( 67 | expr: Expr, 68 | actions: EvalActions, 69 | ): Promise => { 70 | return match, Expr>(expr, { 71 | Call: async ({ name, args }) => getFunctions(name, args, actions), 72 | LiteralString: async s => EvalValue.String(s), 73 | LiteralNumber: async ({ value, unit }) => 74 | EvalValue.Number( 75 | matchString(unit, { 76 | s: () => value * 1000, 77 | rem: () => value * 16, // TODO: get root font size 78 | em: () => value * 16, // TODO: get parent font size 79 | '%': () => value * 100, // TODO: Get parent width 80 | _: () => value, 81 | }), 82 | ), 83 | Identifier: async s => EvalValue.String(s), 84 | VarIdentifier: async s => EvalValue.VarIdentifier(s), 85 | Parens: ({ expr }) => evalExpr(expr, actions), 86 | _: async _ => EvalValue.Void(), 87 | }) 88 | } 89 | 90 | const QUOTE_REGEX = /^['"](.*)(?=['"]$)['"]$/g 91 | const unquotify = (s: string) => s.replace(QUOTE_REGEX, '$1') 92 | 93 | export const evalValueToString = (val: EvalValue): string | undefined => 94 | match(val, { 95 | String: s => unquotify(s), 96 | Boolean: b => `${b}`, 97 | Number: n => `${n}`, 98 | VarIdentifier: s => s, 99 | Value: v => `${v}`, 100 | _: () => undefined, 101 | }) 102 | 103 | const evalValueToNumber = (val: EvalValue): number | undefined => 104 | match(val, { 105 | String: s => parseFloat(s), 106 | Boolean: b => (b ? 1 : 0), 107 | Number: n => n, 108 | Value: v => parseFloat(v), 109 | _: () => undefined, 110 | }) 111 | 112 | const evalValueToBoolean = (val: EvalValue): boolean => 113 | match(val, { 114 | String: s => !['false', '', '0'].includes(unquotify(s)), 115 | Boolean: b => b, 116 | Number: n => !!n, 117 | Value: v => !!v, 118 | _: () => false, 119 | }) 120 | 121 | const getFunctions = ( 122 | name: string, 123 | args: Expr[], 124 | actions: EvalActions, 125 | ): Promise => { 126 | const getVariable = async () => { 127 | const varName = await evalExpr(args[0], actions) 128 | const defaultValue = args[1] 129 | ? await evalExpr(args[1], actions) 130 | : EvalValue.Void() 131 | 132 | return match, EvalValue>(varName, { 133 | VarIdentifier: async name => { 134 | const value = await actions.getVariable(name) 135 | return value === undefined ? defaultValue : EvalValue.String(value) 136 | }, 137 | _: async () => EvalValue.Void(), 138 | }) 139 | } 140 | 141 | const jsEval = async () => { 142 | const js = await evalExprAsString(args[0], actions) 143 | const result = js && (await actions.jsEval(js)) 144 | if (result === undefined || result === null) return EvalValue.Void() 145 | return EvalValue.Value(result) 146 | } 147 | 148 | return matchString>(name, { 149 | 'add-class': async () => { 150 | const id = evalValueToString(await evalExpr(args[0], actions)) 151 | const classes = evalValueToString(await evalExpr(args[1], actions)) 152 | if (id && classes) { 153 | await actions.addClass(id, classes) 154 | } 155 | return EvalValue.Void() 156 | }, 157 | 'remove-class': async () => { 158 | const id = evalValueToString(await evalExpr(args[0], actions)) 159 | const classes = evalValueToString(await evalExpr(args[1], actions)) 160 | if (id && classes) { 161 | await actions.removeClass(id, classes) 162 | } 163 | return EvalValue.Void() 164 | }, 165 | 166 | if: async () => { 167 | const cond = evalValueToBoolean(await evalExpr(args[0], actions)) 168 | if (cond) { 169 | return evalExpr(args[1], actions) 170 | } else { 171 | return evalExpr(args[2], actions) 172 | } 173 | }, 174 | delay: async () => { 175 | const num = evalValueToNumber(await evalExpr(args[0], actions)) 176 | num !== undefined ? await actions.delay(num) : undefined 177 | return EvalValue.Void() 178 | }, 179 | 180 | 'js-eval': jsEval, 181 | 'js-expr': jsEval, 182 | 183 | 'load-cssx': async () => { 184 | const id = evalValueToString(await evalExpr(args[0], actions)) 185 | const url = evalValueToString(await evalExpr(args[1], actions)) 186 | if (id && url) { 187 | await actions.loadCssx(id, url) 188 | } 189 | return EvalValue.Void() 190 | }, 191 | 192 | var: getVariable, 193 | 'get-var': getVariable, 194 | 195 | update: async () => { 196 | const [id, name, value] = 197 | args.length >= 3 198 | ? (await evalArgs(args, 3, actions)).map(evalValueToString) 199 | : [ 200 | undefined, 201 | ...(await evalArgs(args, 2, actions)).map(evalValueToString), 202 | ] 203 | if (name) { 204 | await actions.updateVariable(id ?? undefined, name, value ?? '') 205 | } 206 | return EvalValue.Void() 207 | }, 208 | 209 | 'set-attr': async () => { 210 | const [id, name, value] = 211 | args.length >= 3 212 | ? (await evalArgs(args, 3, actions)).map(evalValueToString) 213 | : [ 214 | undefined, 215 | ...(await evalArgs(args, 2, actions)).map(evalValueToString), 216 | ] 217 | if (name) { 218 | actions.setAttribute(id ?? undefined, name, value ?? '') 219 | } 220 | return EvalValue.Void() 221 | }, 222 | attr: async () => { 223 | const [id, name] = 224 | args.length >= 2 225 | ? (await evalArgs(args, 2, actions)).map(evalValueToString) 226 | : [undefined, evalValueToString(await evalExpr(args[0], actions))] 227 | if (name) { 228 | const val = await actions.getAttribute(id as string | undefined, name) 229 | return val === undefined ? EvalValue.Void() : EvalValue.String(val) 230 | } 231 | return EvalValue.Void() 232 | }, 233 | 234 | 'prevent-default': async () => { 235 | await actions.withEvent(e => e.preventDefault()) 236 | return EvalValue.Void() 237 | }, 238 | 239 | request: async () => { 240 | const url = evalValueToString(await evalExpr(args[0], actions)) 241 | const method = 242 | (args[1] && evalValueToString(await evalExpr(args[1], actions))) || 243 | 'post' 244 | 245 | if (url) { 246 | const data = await actions.getFormData() 247 | await actions.sendRequest({ method, url, data }) 248 | } 249 | return EvalValue.Void() 250 | }, 251 | 252 | 'add-children': async () => { 253 | const id = evalValueToString(await evalExpr(args[0], actions)) 254 | if (id) await actions.addChildren(id, args.slice(1)) 255 | return EvalValue.Void() 256 | }, 257 | 'remove-element': async () => { 258 | const selector = 259 | (args[0] && evalValueToString(await evalExpr(args[0], actions))) ?? 260 | undefined 261 | if (selector) await actions.removeElement(selector) 262 | return EvalValue.Void() 263 | }, 264 | 265 | 'call-method': async () => { 266 | const [id, method, ...methodArgs] = ( 267 | await Promise.all(args.map(a => evalExpr(a, actions))) 268 | ).map(evalValueToString) 269 | if (id && method) { 270 | await actions.callMethod(id, method, methodArgs) 271 | } 272 | return EvalValue.Void() 273 | }, 274 | 275 | map: async () => { 276 | const values = await Promise.all( 277 | args.map(async mapExpr => 278 | match, Expr>(mapExpr, { 279 | Pair: async ({ key, value }) => [ 280 | key, 281 | await evalExpr(value, actions), 282 | ], 283 | _: async () => undefined, 284 | }), 285 | ), 286 | ) 287 | 288 | return EvalValue.Map(Object.fromEntries(values.filter(Boolean) as any)) 289 | }, 290 | 291 | seq: async () => EvalValue.Lazy(args), 292 | 293 | // noop 294 | noop: async () => EvalValue.Void(), 295 | func: async () => EvalValue.Void(), 296 | 297 | call: async () => { 298 | const varId = match( 299 | await evalExpr(args[0], actions), 300 | { 301 | VarIdentifier: id => id, 302 | _: () => undefined, 303 | }, 304 | ) 305 | 306 | const propMapExpr = args[1] 307 | ? await evalExpr(args[1], actions) 308 | : EvalValue.Void() 309 | const properties = match, EvalValue>( 310 | propMapExpr, 311 | { 312 | Map: m => m, 313 | _: () => ({}), 314 | }, 315 | ) 316 | 317 | if (varId) { 318 | const prop = await actions.getVariable(varId) 319 | if (prop) { 320 | const exprs = parse(prop) 321 | return actions.evaluateInScope(exprs, properties) 322 | } 323 | } 324 | 325 | return EvalValue.Void() 326 | }, 327 | 328 | string: async () => { 329 | const str = await Promise.all(args.map(a => evalExprAsString(a, actions))) 330 | return EvalValue.String(str.filter(Boolean).join('')) 331 | }, 332 | quotify: async () => { 333 | const str = await evalExprAsString(args[0], actions) 334 | return EvalValue.String(`'${str || ''}'`) 335 | }, 336 | unquotify: async () => { 337 | const str = await evalExprAsString(args[0], actions) 338 | return EvalValue.String(unquotify(str || '')) 339 | }, 340 | 341 | try: async () => { 342 | try { 343 | return await evalExpr(args[0], actions) 344 | } catch (e) { 345 | return actions.evaluateInScope([args[1]], { 346 | '--error': EvalValue.Value(e), 347 | }) 348 | } 349 | }, 350 | 351 | do: async () => { 352 | let result = EvalValue.Void() 353 | for (const expr of args) { 354 | result = await evalExpr(expr, actions) 355 | } 356 | return result 357 | }, 358 | 359 | let: async () => { 360 | const varName = await evalExprAsString(args[0], actions) 361 | const result = await evalExpr(args[1], actions) 362 | if (!varName) return EvalValue.Void() 363 | 364 | return actions.evaluateInScope([args[2]], { 365 | [varName]: result, 366 | }) 367 | }, 368 | 369 | calc: async () => { 370 | const result = await evalCalcExpr(args[0], actions) 371 | return EvalValue.Number(result) 372 | }, 373 | 374 | // TODO: Structural comparison? 375 | equals: async () => 376 | compare( 377 | args[0], 378 | args[1], 379 | actions, 380 | (a, b) => evalValueToString(a) === evalValueToString(b), 381 | ), 382 | 383 | gt: async () => 384 | compare( 385 | args[0], 386 | args[1], 387 | actions, 388 | (a, b) => (evalValueToNumber(a) ?? 0) > (evalValueToNumber(b) ?? 0), 389 | ), 390 | 391 | lt: async () => 392 | compare( 393 | args[0], 394 | args[1], 395 | actions, 396 | (a, b) => (evalValueToNumber(a) ?? 0) < (evalValueToNumber(b) ?? 0), 397 | ), 398 | 399 | gte: async () => 400 | compare( 401 | args[0], 402 | args[1], 403 | actions, 404 | (a, b) => (evalValueToNumber(a) ?? 0) >= (evalValueToNumber(b) ?? 0), 405 | ), 406 | 407 | lte: async () => 408 | compare( 409 | args[0], 410 | args[1], 411 | actions, 412 | (a, b) => (evalValueToNumber(a) ?? 0) <= (evalValueToNumber(b) ?? 0), 413 | ), 414 | 415 | _: () => Promise.reject(new Error(`Not implemented: ${name}`)), 416 | }) 417 | } 418 | 419 | export const compare = async ( 420 | a: Expr, 421 | b: Expr, 422 | actions: EvalActions, 423 | cmp: (a: EvalValue, b: EvalValue) => boolean, 424 | ) => 425 | EvalValue.Boolean(cmp(await evalExpr(a, actions), await evalExpr(b, actions))) 426 | 427 | const evalBinOp = async ( 428 | left: Expr, 429 | right: Expr, 430 | actions: EvalActions, 431 | op: (a: number, b: number) => number, 432 | ): Promise => 433 | op(await evalCalcExpr(left, actions), await evalCalcExpr(right, actions)) 434 | 435 | export const evalCalcExpr = ( 436 | expr: Expr, 437 | actions: EvalActions, 438 | ): Promise => 439 | match(expr, { 440 | BinOp: async ({ op, left, right }) => 441 | matchString(op, { 442 | '+': () => evalBinOp(left, right, actions, (a, b) => a + b), 443 | '*': () => evalBinOp(left, right, actions, (a, b) => a * b), 444 | '-': () => evalBinOp(left, right, actions, (a, b) => a - b), 445 | '/': () => evalBinOp(left, right, actions, (a, b) => a / b), 446 | _: () => 447 | Promise.reject( 448 | new Error(`Invalid operator in calc expression: ${op}`), 449 | ), 450 | }), 451 | Parens: ({ expr }) => evalCalcExpr(expr, actions), 452 | _: async () => { 453 | // Special expressions to double-evaluate 454 | if (expr.tag === 'Call' && ['var', 'get-var'].includes(expr.value.name)) { 455 | const value = await evalExprAsString(expr, actions) 456 | try { 457 | const pvalue = await evalExpr(parseExpr(value ?? ''), actions) 458 | return evalValueToNumber(pvalue) ?? 0 459 | } catch (e) { 460 | return 0 461 | } 462 | } 463 | const value = await evalExpr(expr, actions) 464 | return evalValueToNumber(value) ?? 0 465 | }, 466 | }) 467 | 468 | export const evalArgs = ( 469 | args: Array, 470 | count: number, 471 | actions: EvalActions, 472 | ) => Promise.all(args.slice(0, count).map(e => evalExpr(e, actions))) 473 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EvalActions, 3 | EvalValue, 4 | evalExpr, 5 | evalExprAsString, 6 | evalValueToString, 7 | } from './eval' 8 | import { 9 | extractDeclaration, 10 | expressionsToDeclrs, 11 | Declaration, 12 | } from './declarations' 13 | import { Expr, parse } from './parser' 14 | import { match, matchString } from './utils/adt' 15 | 16 | const CSSX_ON_UPDATE_EVENT = 'cssx--update' 17 | const CSSX_ON_MOUNT_EVENT = 'cssx--mount' 18 | 19 | const UNSET_PROPERTY_VALUE = '' 20 | const EVENT_HANDLERS = { 21 | click: '--cssx-on-click', 22 | load: '--cssx-on-load', 23 | submit: '--cssx-on-submit', 24 | blur: '--cssx-on-blur', 25 | focus: '--cssx-on-focus', 26 | [CSSX_ON_MOUNT_EVENT]: '--cssx-on-mount', 27 | [CSSX_ON_UPDATE_EVENT]: '--cssx-on-update', 28 | } 29 | 30 | const LAYER_CLASS_NAME = 'cssx-layer' 31 | 32 | const PROPERTIES = { 33 | CHILDREN: '--cssx-children', 34 | TEXT: '--cssx-text', 35 | HTML: '--cssx-disgustingly-set-innerhtml', 36 | } 37 | 38 | export const injectStyles = () => { 39 | const STYLE_TAG_CLASS = 'cssx-style-root' 40 | if (document.querySelector(`.${STYLE_TAG_CLASS}`)) return 41 | 42 | const $style = document.createElement('style') 43 | $style.className = STYLE_TAG_CLASS 44 | 45 | const properties = [ 46 | ...Object.values(PROPERTIES), 47 | ...Object.values(EVENT_HANDLERS), 48 | ] 49 | 50 | $style.textContent = `.cssx-layer { 51 | ${properties.map(p => `${p}: ${UNSET_PROPERTY_VALUE};`).join(' ')} 52 | display: inherit; 53 | width: inherit; 54 | height: inherit; 55 | align-items: inherit; 56 | justify-content: inherit; 57 | }` 58 | 59 | document.body.appendChild($style) 60 | } 61 | 62 | export const getPropertyValue = ($element: HTMLElement, prop: string) => { 63 | let value = `${getComputedStyle($element).getPropertyValue(prop)}`.trim() 64 | value = value.replace(/(^['"])|(['"]$)/gi, '') 65 | return !value || value === UNSET_PROPERTY_VALUE ? '' : value 66 | } 67 | 68 | export const getDeclarations = ( 69 | $element: HTMLElement, 70 | actions: EvalActions, 71 | ): Promise> => { 72 | const value = getPropertyValue($element, PROPERTIES.CHILDREN) 73 | return extractDeclaration(value, actions) 74 | } 75 | 76 | const getElement = ( 77 | id: string, 78 | $node: HTMLElement | Document = document, 79 | ): HTMLElement | null => { 80 | let $element: Node | null = document, 81 | selector: string = id 82 | 83 | if (/^('|")?[a-z0-9_-]+\1$/gi.test(id)) { 84 | selector = `[data-element=${id}]` 85 | } else if (/^:scope/i.test(id)) { 86 | $element = $node 87 | } else if (/^:parent\s+/i.test(id)) { 88 | $element = $node.parentNode 89 | selector = id.replace(/^:parent\s+/i, '') 90 | } 91 | 92 | return ($element as Element)?.querySelector(selector) 93 | } 94 | 95 | const getEvalActions = ( 96 | $element: HTMLElement, 97 | ctx: { event?: any; pure?: boolean }, 98 | ): EvalActions => { 99 | const { event = null, pure = false } = ctx 100 | 101 | const actions: EvalActions = { 102 | addClass: async (id, cls) => getElement(id, $element)?.classList.add(cls), 103 | removeClass: async (id, cls) => 104 | getElement(id, $element)?.classList.remove(cls), 105 | delay: delay => new Promise(res => setTimeout(res, delay)), 106 | jsEval: async js => !pure && (0, eval)(js), 107 | loadCssx: async (id, url) => 108 | pure 109 | ? '' 110 | : new Promise((resolve, reject) => { 111 | const $link = Object.assign(document.createElement('link'), { 112 | href: url, 113 | rel: 'stylesheet', 114 | }) 115 | $link.onload = () => { 116 | const $el = getElement(id, $element) 117 | if ($el) { 118 | manageElement($el) 119 | resolve(id) 120 | } else { 121 | console.error(`[CSSX] Unable to find root for ${id}`) 122 | reject(`[CSSX] Unable to find root for ${id}`) 123 | } 124 | } 125 | document.body.appendChild($link) 126 | }), 127 | getVariable: async varName => getPropertyValue($element, varName), 128 | updateVariable: async (targetId, varName, value) => { 129 | const $el = targetId ? getElement(targetId, $element) : $element 130 | const isCustomProp = varName.startsWith('--') 131 | if ($el) { 132 | const prevValue = getPropertyValue($el, varName) 133 | if (isCustomProp) { 134 | ;($el as any).style.setProperty(varName, JSON.stringify(value)) 135 | } else { 136 | ;($el as any).style[varName] = value 137 | } 138 | 139 | if (JSON.stringify(value) !== prevValue && isCustomProp) { 140 | const detail = { name: varName, value, prevValue } 141 | $el.dispatchEvent(new CustomEvent(CSSX_ON_UPDATE_EVENT, { detail })) 142 | } 143 | } 144 | }, 145 | setAttribute: async (id, name, value) => { 146 | const $el = id ? getElement(id, $element) : $element 147 | if (name === 'value') { 148 | ;($el as any).value = value 149 | } else if (value) { 150 | $el?.setAttribute(name, value) 151 | } else { 152 | $el?.removeAttribute(name) 153 | } 154 | }, 155 | getAttribute: async (id, name) => { 156 | const $el = id ? getElement(id, $element) : $element 157 | if (name === 'value') return ($el as any).value 158 | return $el?.getAttribute(name) ?? undefined 159 | }, 160 | withEvent: async fn => event && fn(event), 161 | getFormData: async () => 162 | $element.nodeName === 'FORM' 163 | ? new FormData($element as HTMLFormElement) 164 | : undefined, 165 | sendRequest: async ({ url, method, data }) => { 166 | if (pure) return 167 | await fetch(url, { method, body: data }) 168 | // TODO: Handle response? 169 | }, 170 | addChildren: async (id, children) => { 171 | const $el = getElement(id, $element) 172 | const declarations = await expressionsToDeclrs(children, actions) 173 | $el && createLayer(declarations, $el) 174 | }, 175 | removeElement: async id => { 176 | const $el = id ? getElement(id, $element) : $element 177 | $el?.parentNode?.removeChild($el) 178 | }, 179 | callMethod: async (id, method, args) => { 180 | const $el = id ? getElement(id, $element) : $element 181 | ;($el as any)[method].call($el, args) 182 | }, 183 | 184 | evaluateInScope: async (exprs, properties) => { 185 | const node = document.createElement('div') 186 | node.style.display = 'none' 187 | 188 | for (const [key, evalVal] of Object.entries(properties)) { 189 | const value = evalValueToString(evalVal) 190 | value && node.style.setProperty(key, value) 191 | } 192 | $element.appendChild(node) 193 | 194 | const result = await evalExprInScope(exprs, getEvalActions(node, ctx)) 195 | 196 | if (!$element.hasAttribute('data-debug-stack')) { 197 | node.parentNode?.removeChild(node) 198 | } 199 | 200 | return result 201 | }, 202 | } 203 | return actions 204 | } 205 | 206 | const evalExprInScope = async ( 207 | exprs: Expr[], 208 | actions: EvalActions, 209 | ): Promise => { 210 | let lastVal = EvalValue.Void() 211 | for (const expr of exprs) { 212 | lastVal = await evalExpr(expr, actions) 213 | } 214 | return lastVal 215 | } 216 | 217 | export const handleEvents = async ( 218 | $element: HTMLElement, 219 | isNewElement: boolean = false, 220 | ) => { 221 | for (const [eventType, property] of Object.entries(EVENT_HANDLERS)) { 222 | const handlerExpr = getPropertyValue($element, property) 223 | 224 | if (handlerExpr) { 225 | const eventHandler = async (event: any) => { 226 | await evalExprInScope( 227 | parse(handlerExpr), 228 | getEvalActions($element, { event }), 229 | ) 230 | } 231 | 232 | matchString(eventType, { 233 | [CSSX_ON_UPDATE_EVENT]: () => { 234 | if (!$element.hasAttribute('data-hooked')) { 235 | $element.addEventListener(eventType, eventHandler) 236 | $element.setAttribute('data-hooked', 'true') 237 | } 238 | }, 239 | [CSSX_ON_MOUNT_EVENT]: () => { 240 | if (isNewElement) setTimeout(eventHandler) 241 | }, 242 | _: () => { 243 | ;($element as any)[`on${eventType}`] = eventHandler 244 | }, 245 | }) 246 | } 247 | } 248 | } 249 | 250 | const declarationToElement = async ( 251 | declaration: Declaration, 252 | $parent?: HTMLElement, 253 | ): Promise<{ node: HTMLElement; isNewElement: boolean }> => { 254 | const { tag, id, selectors } = declaration.selector 255 | const tagName = tag || 'div' 256 | 257 | let $child = $parent?.querySelector(`:scope > #${id}`) 258 | const isNewElement = !$child 259 | if (!$child) { 260 | $child = Object.assign(document.createElement(tagName), { id }) 261 | $child.dataset.element = id 262 | } 263 | 264 | // Add selectors 265 | for (const selector of selectors) { 266 | match(selector, { 267 | ClassName: cls => 268 | !$child?.classList.contains(cls) && $child?.classList.add(cls), 269 | Attr: ([key, val]) => $child?.setAttribute(key, val), 270 | }) 271 | } 272 | 273 | for (const [key, evalValue] of declaration.properties) { 274 | const value = evalValueToString(evalValue) 275 | $child?.style.setProperty(key, JSON.stringify(value || '')) 276 | } 277 | 278 | return { node: $child, isNewElement } 279 | } 280 | 281 | const createLayer = async ( 282 | declarations: Array, 283 | $parent: HTMLElement, 284 | ) => { 285 | const $childrenRoot = 286 | $parent?.querySelector(`:scope > .${LAYER_CLASS_NAME}`) ?? 287 | Object.assign(document.createElement('div'), { 288 | className: LAYER_CLASS_NAME, 289 | }) 290 | 291 | if (!$childrenRoot.parentNode) $parent.appendChild($childrenRoot) 292 | 293 | for (const declaration of declarations) { 294 | const { node: $child, isNewElement } = await declarationToElement( 295 | declaration, 296 | $childrenRoot, 297 | ) 298 | $childrenRoot.appendChild($child) 299 | await manageElement($child, isNewElement) 300 | 301 | if (declaration.children.length > 0) { 302 | await createLayer(declaration.children, $child) 303 | } 304 | } 305 | } 306 | 307 | export const manageElement = async ( 308 | $element: HTMLElement, 309 | isNewElement: boolean = false, 310 | ) => { 311 | await handleEvents($element, isNewElement) 312 | 313 | const actions = getEvalActions($element, { pure: true }) 314 | 315 | const text = getPropertyValue($element, PROPERTIES.TEXT) 316 | if (text) { 317 | try { 318 | const exprs = parse(text) 319 | $element.textContent = 320 | (exprs[0] ? await evalExprAsString(exprs[0], actions) : text) ?? text 321 | } catch (e) { 322 | $element.textContent = text 323 | } 324 | } 325 | 326 | const html = getPropertyValue($element, PROPERTIES.HTML) 327 | if (html) $element.innerHTML = html.replace(/(^'|")|('|"$)/g, '') 328 | 329 | const declarations = await getDeclarations($element, actions) 330 | if (declarations.length > 0) { 331 | await createLayer(declarations, $element) 332 | } 333 | } 334 | 335 | export interface Options { 336 | root?: HTMLElement 337 | } 338 | export const render = async ({ root = document.body }: Options = {}) => { 339 | injectStyles() 340 | await manageElement(root) 341 | } 342 | -------------------------------------------------------------------------------- /src/parser.ts: -------------------------------------------------------------------------------- 1 | import { Enum, constructors, match, matchString } from './utils/adt' 2 | import * as P from './utils/parser-comb' 3 | import { Result } from './utils/result' 4 | 5 | // TODO: vh, vw 6 | export type CSSUnit = '' | 's' | 'ms' | 'px' | '%' | 'rem' | 'em' 7 | 8 | export type BinOp = '+' | '-' | '*' | '/' 9 | 10 | export interface Selector { 11 | tag: string | undefined 12 | id: string 13 | selectors: Array 14 | } 15 | 16 | export type SelectorComp = Enum<{ 17 | ClassName: string 18 | Attr: readonly [string, string] 19 | }> 20 | export const SelectorComp = constructors() 21 | 22 | export type Expr = Enum<{ 23 | Call: { name: string; args: Expr[] } 24 | Identifier: string 25 | VarIdentifier: string 26 | LiteralString: string 27 | LiteralNumber: { value: number; unit: CSSUnit } 28 | BinOp: { op: BinOp; left: Expr; right: Expr } 29 | Parens: { expr: Expr } 30 | 31 | Pair: { key: string; value: Expr } 32 | Selector: Selector 33 | }> 34 | 35 | export const Expr = constructors() 36 | 37 | const whitespace = P.regex(/^\s*/) 38 | const consumeWhitespace = (p: P.Parser): P.Parser => 39 | P.between(whitespace, p, whitespace) 40 | const comma = consumeWhitespace(P.string(',')) 41 | const parens = (p: P.Parser): P.Parser => 42 | P.between(P.string('('), p, P.string(')')) 43 | const identifierParser = P.regex(/^[a-z][a-z0-9_-]*/i) 44 | const varIdentifierParser = P.regex(/^--[a-z][a-z0-9-]*/i) 45 | const singleQuote = P.string("'") 46 | const doubleQuote = P.string('"') 47 | 48 | const identifierExprParser = P.map(identifierParser, Expr.Identifier) 49 | const varIdentifierExprParser = P.map(varIdentifierParser, Expr.VarIdentifier) 50 | 51 | const callExprParser = 52 | (fnParser?: P.Parser, argParser?: P.Parser) => 53 | (input: string) => 54 | P.map( 55 | P.zip2( 56 | consumeWhitespace(fnParser ?? identifierParser), 57 | parens(consumeWhitespace(P.sepBy(argParser ?? exprParser, comma))), 58 | ), 59 | ([name, args]) => Expr.Call({ name, args }), 60 | )(input) 61 | 62 | const stringLiteralParser = P.or([ 63 | P.between(singleQuote, P.regex(/^[^']*/), singleQuote), 64 | P.between(doubleQuote, P.regex(/^[^"]*/), doubleQuote), 65 | ]) 66 | const stringLiteralExprParser: P.Parser = P.map( 67 | stringLiteralParser, 68 | Expr.LiteralString, 69 | ) 70 | 71 | const unitParser = P.regex(/^(s|ms|%|px|rem|em)/i) 72 | const numberParser = P.regex(/^[-+]?((\d*\.\d+)|\d+)/) 73 | const numberExprParser: P.Parser = P.map( 74 | P.zip2(numberParser, P.optional(unitParser)), 75 | ([value, unit]) => 76 | Expr.LiteralNumber({ value: Number(value), unit: (unit ?? '') as CSSUnit }), 77 | ) 78 | 79 | const tagP = identifierParser 80 | const idP = P.prefixed(P.string('#'), identifierParser) 81 | const classP = P.map( 82 | P.prefixed(P.string('.'), identifierParser), 83 | SelectorComp.ClassName, 84 | ) 85 | const valueP = P.or([identifierParser, stringLiteralParser, numberParser]) 86 | const attrP = P.map( 87 | P.between( 88 | P.string('['), 89 | P.zip2(P.suffixed(identifierParser, P.string('=')), valueP), 90 | P.string(']'), 91 | ), 92 | SelectorComp.Attr, 93 | ) 94 | 95 | const selectorExprParser: P.Parser = (input: string) => 96 | P.map( 97 | P.zip2(P.zip2(P.optional(tagP), idP), P.many0(P.or([classP, attrP]))), 98 | ([[tag, id], selectors]) => Expr.Selector({ tag, id, selectors }), 99 | )(input) 100 | 101 | const pairExprParser: P.Parser = (input: string) => 102 | P.map( 103 | P.zip2( 104 | P.suffixed(varIdentifierParser, consumeWhitespace(P.string(':'))), 105 | exprParser, 106 | ), 107 | ([key, value]) => Expr.Pair({ key, value }), 108 | )(input) 109 | 110 | const precedence = (op: BinOp) => 111 | matchString(op, { 112 | '+': () => 0, 113 | '-': () => 0, 114 | '*': () => 1, 115 | '/': () => 2, 116 | _: () => -1, 117 | }) 118 | 119 | const binOpWithFixitySwitchity = (op: BinOp, left: Expr, right: Expr): Expr => 120 | match(right, { 121 | BinOp: binOp => { 122 | if (precedence(op) >= precedence(binOp.op)) { 123 | return Expr.BinOp({ 124 | op: binOp.op, 125 | left: binOpWithFixitySwitchity(op, left, binOp.left), 126 | right: binOp.right, 127 | }) 128 | } 129 | return Expr.BinOp({ op, left, right }) 130 | }, 131 | Parens: ({ expr }) => Expr.BinOp({ op, left, right: expr }), 132 | _: () => Expr.BinOp({ op, left, right }), 133 | }) 134 | 135 | const allowParens = (p: P.Parser): P.Parser => 136 | P.or([P.map(parens(p), expr => Expr.Parens({ expr })), p]) 137 | 138 | const binOpP = P.regex(/^[+\-*/]/) 139 | 140 | const binOpExprParser: P.Parser = allowParens((input: string) => 141 | match(exprParser(input), { 142 | Ok: ({ value, input: rest }) => 143 | P.map( 144 | P.optional(P.zip2(consumeWhitespace(binOpP), binOpExprParser)), 145 | res => 146 | res 147 | ? binOpWithFixitySwitchity(res[0] as BinOp, value, res[1]) 148 | : value, 149 | )(rest), 150 | Err: _ => Result.Ok({ value: [], input }), 151 | }), 152 | ) 153 | 154 | const exprParser: P.Parser = allowParens( 155 | P.or([ 156 | stringLiteralExprParser, 157 | numberExprParser, 158 | callExprParser(P.string('calc'), binOpExprParser), 159 | callExprParser(), 160 | pairExprParser, 161 | varIdentifierExprParser, 162 | selectorExprParser, 163 | identifierExprParser, 164 | ]), 165 | ) 166 | 167 | export const parseExpr = (input: string): Expr => { 168 | return match(exprParser(input), { 169 | Ok: ({ value, input }) => { 170 | if (input) throw new Error(`Aaaaaa. Input left: ${input}`) 171 | return value 172 | }, 173 | Err: e => { 174 | throw e 175 | }, 176 | }) 177 | } 178 | 179 | const declarationParser = P.or([callExprParser(), selectorExprParser]) 180 | 181 | const multiDeclarationParser = P.sepBy(declarationParser, whitespace) 182 | 183 | export const parseDeclarations = (input: string) => 184 | match, P.ParseResult>>( 185 | multiDeclarationParser(input), 186 | { 187 | Ok: ({ value, input }) => { 188 | if (input) { 189 | console.error(`Declaration stopped parsing at: "${input}"`) 190 | } 191 | return value 192 | }, 193 | Err: ({ error }) => { 194 | console.error(error) 195 | return [] 196 | }, 197 | }, 198 | ) 199 | 200 | export const parse = (input: string): Array => { 201 | const res = P.sepBy(exprParser, P.or([comma, whitespace]))(input.trim()) 202 | return match(res, { 203 | Ok: ({ value, input }) => { 204 | if (input) { 205 | throw new Error(`Input not consumed completely here brosky: "${input}"`) 206 | } 207 | return value 208 | }, 209 | Err: ({ error, input }) => { 210 | throw new Error(`${error}.\n Left input: ${input.slice(0, 20)}...`) 211 | }, 212 | }) 213 | } 214 | -------------------------------------------------------------------------------- /src/renderer.ts: -------------------------------------------------------------------------------- 1 | import { render } from '.' 2 | 3 | render({ root: document.body }) 4 | -------------------------------------------------------------------------------- /src/utils/adt.ts: -------------------------------------------------------------------------------- 1 | type TagValue = T extends Tag ? V : never 2 | 3 | export const match = >( 4 | tag: T, 5 | pattern: { 6 | [key in T['tag'] | '_']?: (v: TagValue) => R 7 | }, 8 | ): R => ((pattern as any)[tag.tag] || (pattern._ as any))(tag.value) 9 | 10 | // type TagValues< 11 | // T extends Tag, 12 | // Keys extends Array, 13 | // Values extends Array = [], 14 | // > = Keys extends [] 15 | // ? Values 16 | // : Keys extends [ 17 | // infer key extends string, 18 | // ...infer restOfKeys extends string[], 19 | // ] 20 | // ? TagValues]> 21 | // : never 22 | // 23 | // export const ifLet = , Keys extends Array>( 24 | // tag: T, 25 | // kinds: Keys, 26 | // cb: (...values: TagValues) => void, 27 | // ): void => { 28 | // const values = kinds.map(k => (tag.tag === k ? tag.value : undefined)) 29 | // ;(cb as any)(...values) 30 | // } 31 | 32 | export const matchString = ( 33 | key: T, 34 | pattern: { 35 | [key in T | '_']?: (key: key) => R 36 | }, 37 | ): R => ((pattern as any)[key] || (pattern._ as any))(key) 38 | 39 | type Tag = { tag: N; value: V } 40 | export type Enum = { [N in keyof T]: Tag }[keyof T] 41 | 42 | export const constructors = >(): { 43 | [N in T['tag']]: TagValue extends null | never 44 | ? (value?: null | never) => T 45 | : (value: TagValue) => T 46 | } => 47 | new Proxy( 48 | {}, 49 | { 50 | get(_, k) { 51 | return (value: any) => ({ tag: k, value }) 52 | }, 53 | }, 54 | ) as any 55 | -------------------------------------------------------------------------------- /src/utils/parser-comb.ts: -------------------------------------------------------------------------------- 1 | import { match } from './adt' 2 | import { Result, mapResult, chainResult } from './result' 3 | 4 | export type ParseResult = Result< 5 | { value: T; input: string }, 6 | { error: string; input: string } 7 | > 8 | 9 | export type Parser = (input: string) => ParseResult 10 | 11 | export const regex = 12 | (re: RegExp): Parser => 13 | input => { 14 | if (input.length === 0) return Result.Err({ error: 'fuckedinput', input }) 15 | const res = input.match(re) 16 | if (!res) return Result.Err({ error: 'fucked', input }) 17 | return Result.Ok({ value: res[0], input: input.replace(re, '') }) 18 | } 19 | 20 | export const string = 21 | (str: string): Parser => 22 | input => { 23 | if (input.length === 0) return Result.Err({ error: 'fuckedinput', input }) 24 | if (!input.startsWith(str)) 25 | return Result.Err({ error: 'fuckedstring', input }) 26 | return Result.Ok({ value: str, input: input.slice(str.length) }) 27 | } 28 | 29 | export const or = 30 | ([parser, ...rest]: Array>): Parser => 31 | input => { 32 | if (rest.length === 0) return parser(input) 33 | const result = parser(input) 34 | return match(result, { 35 | Ok: () => result, 36 | Err: _ => or(rest)(input), 37 | }) 38 | } 39 | 40 | export const mapParseResult = 41 | ( 42 | parser: Parser, 43 | fn: (_: { value: T; input: string }) => { value: R; input: string }, 44 | ): Parser => 45 | input => 46 | mapResult(parser(input), fn) 47 | 48 | export const map = (parser: Parser, fn: (_: T) => R): Parser => 49 | mapParseResult(parser, ({ value, ...rest }) => ({ 50 | ...rest, 51 | value: fn(value), 52 | })) 53 | 54 | export const zip2 = 55 | (parserA: Parser, parserB: Parser): Parser => 56 | input => { 57 | // TODO: refactor please. shit code 58 | const resa: Result< 59 | { value: A; input: string }, 60 | { error: string; input: string } 61 | > = parserA(input) 62 | return chainResult(resa, ({ value: a, input: inputB }) => { 63 | const res: Result< 64 | { value: readonly [A, B]; input: string }, 65 | { error: string; input: string } 66 | > = map(parserB, b => [a, b] as const)(inputB) 67 | return res 68 | }) 69 | } 70 | 71 | export const prefixed = ( 72 | parserPrefix: Parser, 73 | parser: Parser, 74 | ): Parser => map(zip2(parserPrefix, parser), ([_, a]) => a) 75 | 76 | export const suffixed = ( 77 | parser: Parser, 78 | parserSuffix: Parser, 79 | ): Parser => map(zip2(parser, parserSuffix), ([a, _]) => a) 80 | 81 | export const between = ( 82 | prefix: Parser, 83 | parser: Parser, 84 | suffix: Parser, 85 | ): Parser => suffixed(prefixed(prefix, parser), suffix) 86 | 87 | export const many0 = 88 | (parser: Parser): Parser> => 89 | originalInput => 90 | match(parser(originalInput), { 91 | Ok: ({ value, input }) => map(many0(parser), ls => [value, ...ls])(input), 92 | Err: ({ input }) => Result.Ok({ value: [], input }), 93 | }) 94 | 95 | export const many1 = 96 | (parser: Parser): Parser> => 97 | originalInput => 98 | match(parser(originalInput), { 99 | Ok: ({ value, input }) => map(many0(parser), ls => [value, ...ls])(input), 100 | Err: err => Result.Err(err), 101 | }) 102 | 103 | export const sepBy = 104 | (parser: Parser, sepP: Parser): Parser> => 105 | originalInput => 106 | match(parser(originalInput), { 107 | Ok: ({ value, input }) => 108 | map(many0(prefixed(sepP, parser)), ls => [value, ...ls])(input), 109 | Err: _ => Result.Ok({ value: [], input: originalInput }), 110 | }) 111 | 112 | export const optional = 113 | (parser: Parser): Parser => 114 | input => { 115 | const result = parser(input) 116 | return match(result, { 117 | Ok: _ => result, 118 | Err: _ => Result.Ok({ value: undefined, input }), 119 | }) 120 | } 121 | -------------------------------------------------------------------------------- /src/utils/result.ts: -------------------------------------------------------------------------------- 1 | import { Enum, constructors, match } from './adt' 2 | 3 | export type Result = Enum<{ Ok: V; Err: E }> 4 | export const Result = constructors>() 5 | 6 | export const mapResult = ( 7 | res: Result, 8 | fn: (_: A) => B, 9 | ): Result => chainResult(res, a => Result.Ok(fn(a))) 10 | 11 | export const chainResult = ( 12 | res: Result, 13 | fn: (_: A) => Result, 14 | ): Result => 15 | match(res, { 16 | Ok: a => fn(a), 17 | Err: e => Result.Err(e), 18 | }) 19 | -------------------------------------------------------------------------------- /tests/calc.spec.ts: -------------------------------------------------------------------------------- 1 | import { EvalActions, EvalValue, evalExpr } from '../src/eval' 2 | import { parseExpr } from '../src/parser' 3 | import { matchString } from '../src/utils/adt' 4 | 5 | describe('calc', () => { 6 | const variables = (name: string) => 7 | matchString(name, { 8 | '--test-8rem': () => '8rem', 9 | _: () => {}, 10 | }) 11 | const actions: EvalActions = { 12 | addClass: jest.fn(), 13 | removeClass: jest.fn(), 14 | delay: jest.fn(), 15 | jsEval: jest.fn(eval), 16 | loadCssx: jest.fn(), 17 | getVariable: jest.fn(variables), 18 | updateVariable: jest.fn(), 19 | setAttribute: jest.fn(), 20 | getAttribute: jest.fn(), 21 | withEvent: jest.fn(), 22 | getFormData: jest.fn(), 23 | sendRequest: jest.fn(), 24 | addChildren: jest.fn(), 25 | removeElement: jest.fn(), 26 | callMethod: jest.fn(), 27 | evaluateInScope: jest.fn(), 28 | } 29 | 30 | describe.each([ 31 | ['calc(8rem)', EvalValue.Number(128)], 32 | ['calc(5 + 8)', EvalValue.Number(13)], 33 | ['calc(5 * 8 + 1)', EvalValue.Number(41)], 34 | ['calc(5 * (8 + 1))', EvalValue.Number(45)], 35 | ['calc(5px * (8rem + 1))', EvalValue.Number(645)], 36 | ['calc(5px * 8rem/2 + 1)', EvalValue.Number(321)], 37 | ['calc(var(--test-8rem))', EvalValue.Number(128)], 38 | ['calc(var(--test-1))', EvalValue.Number(0)], // Var not found 39 | ['calc(5px * var(--test-8rem)/2 + 1)', EvalValue.Number(321)], 40 | ['calc(js-eval("2 * 5"))', EvalValue.Number(10)], 41 | ['calc(9 * js-eval("2 * 5")/2 - 6)', EvalValue.Number(39)], 42 | ['calc(30 - 5 - 3)', EvalValue.Number(22)], 43 | ['calc(30 / 5 / 3)', EvalValue.Number(2)], 44 | ['calc(360 * 6/2 - 90 + 30)', EvalValue.Number(1020)], 45 | [ 46 | 'calc(360 * js-eval("18 / 3")/2 - 90 + (3 * js-eval("2 * 5")))', 47 | EvalValue.Number(1020), 48 | ], 49 | ])('when given "%s"', (expr, expected) => { 50 | it('should evaluate the result of math', async () => { 51 | const evalValue = await evalExpr(parseExpr(expr), actions) 52 | expect(evalValue).toEqual(expected) 53 | }) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /tests/eval.spec.ts: -------------------------------------------------------------------------------- 1 | import { EvalActions, EvalValue, evalExpr } from '../src/eval' 2 | import { Expr, parseExpr } from '../src/parser' 3 | 4 | describe('eval', () => { 5 | const deps: EvalActions = { 6 | addClass: jest.fn(), 7 | removeClass: jest.fn(), 8 | delay: jest.fn(), 9 | jsEval: jest.fn(), 10 | loadCssx: jest.fn(), 11 | getVariable: jest.fn(), 12 | updateVariable: jest.fn(), 13 | setAttribute: jest.fn(), 14 | getAttribute: jest.fn(), 15 | withEvent: jest.fn(), 16 | getFormData: jest.fn(), 17 | sendRequest: jest.fn(), 18 | addChildren: jest.fn(), 19 | removeElement: jest.fn(), 20 | callMethod: jest.fn(), 21 | evaluateInScope: jest.fn(), 22 | } 23 | 24 | fdescribe('function/call', () => { 25 | it('should declare function correctly', async () => { 26 | const evalValue = await evalExpr( 27 | parseExpr(`func(if(get-var(--bool), 'false', 'true'))`), 28 | deps, 29 | ) 30 | expect(evalValue).toEqual( 31 | EvalValue.Lazy([ 32 | Expr.Call({ 33 | name: 'if', 34 | args: [ 35 | Expr.Call({ 36 | name: 'get-var', 37 | args: [Expr.VarIdentifier('--bool')], 38 | }), 39 | Expr.LiteralString('false'), 40 | Expr.LiteralString('true'), 41 | ], 42 | }), 43 | ]), 44 | ) 45 | }) 46 | 47 | it('should allow multiple expressions in func', async () => { 48 | const evalValue = await evalExpr( 49 | parseExpr(`func( 50 | update(--some-var, 'hello world'), 51 | if(get-var(--bool), 'false', 'true') 52 | )`), 53 | deps, 54 | ) 55 | expect(evalValue).toEqual( 56 | EvalValue.Lazy([ 57 | Expr.Call({ 58 | name: 'update', 59 | args: [ 60 | Expr.VarIdentifier('--some-var'), 61 | Expr.LiteralString('hello world'), 62 | ], 63 | }), 64 | Expr.Call({ 65 | name: 'if', 66 | args: [ 67 | Expr.Call({ 68 | name: 'get-var', 69 | args: [Expr.VarIdentifier('--bool')], 70 | }), 71 | Expr.LiteralString('false'), 72 | Expr.LiteralString('true'), 73 | ], 74 | }), 75 | ]), 76 | ) 77 | }) 78 | }) 79 | 80 | it('should add classes', async () => { 81 | await evalExpr( 82 | Expr.Call({ 83 | name: 'add-class', 84 | args: [Expr.Identifier('element-id'), Expr.LiteralString('class-name')], 85 | }), 86 | deps, 87 | ) 88 | 89 | expect(deps.addClass).toHaveBeenCalledTimes(1) 90 | expect(deps.addClass).toHaveBeenCalledWith('element-id', 'class-name') 91 | }) 92 | 93 | it('should allow conditionals classes', async () => { 94 | expect( 95 | await evalExpr( 96 | Expr.Call({ 97 | name: 'if', 98 | args: [ 99 | Expr.Identifier('true'), 100 | Expr.Identifier('yes'), 101 | Expr.Identifier('no'), 102 | ], 103 | }), 104 | deps, 105 | ), 106 | ).toBe('yes') 107 | 108 | expect( 109 | await evalExpr( 110 | Expr.Call({ 111 | name: 'if', 112 | args: [ 113 | Expr.Identifier('false'), 114 | Expr.Identifier('yes'), 115 | Expr.Identifier('no'), 116 | ], 117 | }), 118 | deps, 119 | ), 120 | ).toBe('no') 121 | 122 | expect( 123 | await evalExpr( 124 | Expr.Call({ 125 | name: 'if', 126 | args: [ 127 | Expr.Identifier('0'), 128 | Expr.Identifier('yes'), 129 | Expr.Identifier('no'), 130 | ], 131 | }), 132 | deps, 133 | ), 134 | ).toBe('no') 135 | }) 136 | 137 | it('should remove classes', async () => { 138 | await evalExpr( 139 | Expr.Call({ 140 | name: 'remove-class', 141 | args: [Expr.Identifier('element-id'), Expr.LiteralString('class-name')], 142 | }), 143 | deps, 144 | ) 145 | 146 | expect(deps.removeClass).toHaveBeenCalledTimes(1) 147 | expect(deps.removeClass).toHaveBeenCalledWith('element-id', 'class-name') 148 | }) 149 | 150 | it('should add a delay', async () => { 151 | await evalExpr( 152 | Expr.Call({ 153 | name: 'delay', 154 | args: [Expr.LiteralString('200')], 155 | }), 156 | deps, 157 | ) 158 | 159 | expect(deps.delay).toHaveBeenCalledTimes(1) 160 | expect(deps.delay).toHaveBeenCalledWith(200) 161 | }) 162 | 163 | it('should get variable', async () => { 164 | await evalExpr( 165 | Expr.Call({ 166 | name: 'var', 167 | args: [Expr.LiteralString('--my-var'), Expr.LiteralString('def value')], 168 | }), 169 | deps, 170 | ) 171 | 172 | expect(deps.getVariable).toHaveBeenCalledTimes(1) 173 | expect(deps.getVariable).toHaveBeenCalledWith('--my-var') 174 | }) 175 | }) 176 | -------------------------------------------------------------------------------- /tests/fixtures/signup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Register page 4 | 5 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /tests/fixtures/todo-app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Task destroyer 4 | 5 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /tests/parser.spec.ts: -------------------------------------------------------------------------------- 1 | import { Expr, SelectorComp, parse, parseDeclarations } from '../src/parser' 2 | 3 | describe('parser', () => { 4 | it('parses function call', () => { 5 | expect(parse('hello()')).toEqual([Expr.Call({ name: 'hello', args: [] })]) 6 | expect(parse('hello ( wow , foo ) ')).toEqual([ 7 | Expr.Call({ 8 | name: 'hello', 9 | args: [Expr.Identifier('wow'), Expr.Identifier('foo')], 10 | }), 11 | ]) 12 | expect(parse('hello(wow,foo)')).toEqual([ 13 | Expr.Call({ 14 | name: 'hello', 15 | args: [Expr.Identifier('wow'), Expr.Identifier('foo')], 16 | }), 17 | ]) 18 | expect(parse('hello(wow,foo, coolio)')).toEqual([ 19 | Expr.Call({ 20 | name: 'hello', 21 | args: [ 22 | Expr.Identifier('wow'), 23 | Expr.Identifier('foo'), 24 | Expr.Identifier('coolio'), 25 | ], 26 | }), 27 | ]) 28 | expect(parse('hello(wow)')).toEqual([ 29 | Expr.Call({ name: 'hello', args: [Expr.Identifier('wow')] }), 30 | ]) 31 | }) 32 | 33 | it('parses sequential function calls', () => { 34 | expect(parse('hello(world) foo-doo(bar, baz)')).toEqual([ 35 | Expr.Call({ 36 | name: 'hello', 37 | args: [Expr.Identifier('world')], 38 | }), 39 | Expr.Call({ 40 | name: 'foo-doo', 41 | args: [Expr.Identifier('bar'), Expr.Identifier('baz')], 42 | }), 43 | ]) 44 | }) 45 | 46 | it('parses string literal', () => { 47 | expect(parse(`"hello world toodles ' nice single quote there"`)).toEqual([ 48 | Expr.LiteralString(`hello world toodles ' nice single quote there`), 49 | ]) 50 | 51 | expect(parse(` 'hello world toodles " nice double quote there' `)).toEqual([ 52 | Expr.LiteralString(`hello world toodles " nice double quote there`), 53 | ]) 54 | }) 55 | 56 | it('parses var identifiers', () => { 57 | expect(parse(`var(--hello, 'default')`)).toEqual([ 58 | Expr.Call({ 59 | name: 'var', 60 | args: [Expr.VarIdentifier('--hello'), Expr.LiteralString(`default`)], 61 | }), 62 | ]) 63 | 64 | expect(parse(`calc(var(--hello))`)).toEqual([ 65 | Expr.Call({ 66 | name: 'calc', 67 | args: [ 68 | Expr.Call({ 69 | name: 'var', 70 | args: [Expr.VarIdentifier('--hello')], 71 | }), 72 | ], 73 | }), 74 | ]) 75 | 76 | expect(parse(`update(state-container, --count, var(--count))`)).toEqual([ 77 | Expr.Call({ 78 | name: 'update', 79 | args: [ 80 | Expr.Identifier('state-container'), 81 | Expr.VarIdentifier('--count'), 82 | Expr.Call({ 83 | name: 'var', 84 | args: [Expr.VarIdentifier('--count')], 85 | }), 86 | ], 87 | }), 88 | ]) 89 | }) 90 | 91 | it('parses number and css units', () => { 92 | expect(parse(`100`)).toEqual([Expr.LiteralNumber({ value: 100, unit: '' })]) 93 | expect(parse(`100s`)).toEqual([ 94 | Expr.LiteralNumber({ value: 100, unit: 's' }), 95 | ]) 96 | expect(parse(`100ms`)).toEqual([ 97 | Expr.LiteralNumber({ value: 100, unit: 'ms' }), 98 | ]) 99 | expect(parse(`3.82`)).toEqual([ 100 | Expr.LiteralNumber({ value: 3.82, unit: '' }), 101 | ]) 102 | expect(parse(`3.82s`)).toEqual([ 103 | Expr.LiteralNumber({ value: 3.82, unit: 's' }), 104 | ]) 105 | expect(parse(`3.82ms`)).toEqual([ 106 | Expr.LiteralNumber({ value: 3.82, unit: 'ms' }), 107 | ]) 108 | expect(parse(`-100`)).toEqual([ 109 | Expr.LiteralNumber({ value: -100, unit: '' }), 110 | ]) 111 | expect(parse(`-100s`)).toEqual([ 112 | Expr.LiteralNumber({ value: -100, unit: 's' }), 113 | ]) 114 | expect(parse(`-100ms`)).toEqual([ 115 | Expr.LiteralNumber({ value: -100, unit: 'ms' }), 116 | ]) 117 | expect(parse(`-3.82`)).toEqual([ 118 | Expr.LiteralNumber({ value: -3.82, unit: '' }), 119 | ]) 120 | expect(parse(`-3.82s`)).toEqual([ 121 | Expr.LiteralNumber({ value: -3.82, unit: 's' }), 122 | ]) 123 | expect(parse(`-3.82ms`)).toEqual([ 124 | Expr.LiteralNumber({ value: -3.82, unit: 'ms' }), 125 | ]) 126 | }) 127 | 128 | it('parses pair and map expressions', () => { 129 | expect(parse(`--hello: "foobar is here"`)).toEqual([ 130 | Expr.Pair({ 131 | key: '--hello', 132 | value: Expr.LiteralString('foobar is here'), 133 | }), 134 | ]) 135 | 136 | expect( 137 | parse(`map(--hello: "foobar is here", --test-var : var(--other-var))`), 138 | ).toEqual([ 139 | Expr.Call({ 140 | name: 'map', 141 | args: [ 142 | Expr.Pair({ 143 | key: '--hello', 144 | value: Expr.LiteralString('foobar is here'), 145 | }), 146 | Expr.Pair({ 147 | key: '--test-var', 148 | value: Expr.Call({ 149 | name: 'var', 150 | args: [Expr.VarIdentifier('--other-var')], 151 | }), 152 | }), 153 | ], 154 | }), 155 | ]) 156 | }) 157 | 158 | describe('parseDeclarations', () => { 159 | it('parses complex selectors', () => { 160 | expect( 161 | parseDeclarations(`button#something.my-class[hello=world]`), 162 | ).toEqual([ 163 | Expr.Selector({ 164 | tag: 'button', 165 | id: 'something', 166 | selectors: [ 167 | SelectorComp.ClassName('my-class'), 168 | SelectorComp.Attr(['hello', 'world']), 169 | ], 170 | }), 171 | ]) 172 | 173 | expect( 174 | parseDeclarations( 175 | `#something[data-testid="hello world"].wow input#password[type=password][placeholder="Password: ***"]`, 176 | ), 177 | ).toEqual([ 178 | Expr.Selector({ 179 | tag: undefined, 180 | id: 'something', 181 | selectors: [ 182 | SelectorComp.Attr(['data-testid', 'hello world']), 183 | SelectorComp.ClassName('wow'), 184 | ], 185 | }), 186 | Expr.Selector({ 187 | tag: 'input', 188 | id: 'password', 189 | selectors: [ 190 | SelectorComp.Attr(['type', 'password']), 191 | SelectorComp.Attr(['placeholder', 'Password: ***']), 192 | ], 193 | }), 194 | ]) 195 | }) 196 | 197 | it('parses declarations', () => { 198 | expect( 199 | parseDeclarations( 200 | `instance(button#something, map(--text: "wow", --color: red))`, 201 | ), 202 | ).toEqual([ 203 | Expr.Call({ 204 | name: 'instance', 205 | args: [ 206 | Expr.Selector({ 207 | tag: 'button', 208 | id: 'something', 209 | selectors: [], 210 | }), 211 | Expr.Call({ 212 | name: 'map', 213 | args: [ 214 | Expr.Pair({ key: '--text', value: Expr.LiteralString('wow') }), 215 | Expr.Pair({ key: '--color', value: Expr.Identifier('red') }), 216 | ], 217 | }), 218 | ], 219 | }), 220 | ]) 221 | }) 222 | }) 223 | 224 | describe('calc', () => { 225 | it('parses calc expression', () => { 226 | expect(parse(`calc(50% * 10px + 1px )`)).toEqual([ 227 | Expr.Call({ 228 | name: 'calc', 229 | args: [ 230 | Expr.BinOp({ 231 | op: '+', 232 | left: Expr.BinOp({ 233 | op: '*', 234 | left: Expr.LiteralNumber({ value: 50, unit: '%' }), 235 | right: Expr.LiteralNumber({ value: 10, unit: 'px' }), 236 | }), 237 | right: Expr.LiteralNumber({ value: 1, unit: 'px' }), 238 | }), 239 | ], 240 | }), 241 | ]) 242 | }) 243 | 244 | it('parses calc expression with parens', () => { 245 | expect(parse(`calc((5))`)).toEqual([ 246 | Expr.Call({ 247 | name: 'calc', 248 | args: [ 249 | Expr.Parens({ expr: Expr.LiteralNumber({ value: 5, unit: '' }) }), 250 | ], 251 | }), 252 | ]) 253 | expect(parse(`calc(50% * (10px + 1px) )`)).toEqual([ 254 | Expr.Call({ 255 | name: 'calc', 256 | args: [ 257 | Expr.BinOp({ 258 | op: '*', 259 | left: Expr.LiteralNumber({ value: 50, unit: '%' }), 260 | right: Expr.BinOp({ 261 | op: '+', 262 | left: Expr.LiteralNumber({ value: 10, unit: 'px' }), 263 | right: Expr.LiteralNumber({ value: 1, unit: 'px' }), 264 | }), 265 | }), 266 | ], 267 | }), 268 | ]) 269 | }) 270 | 271 | it('parses calc expression with vars', () => { 272 | expect(parse(`calc(5px * var(--value))`)).toEqual([ 273 | Expr.Call({ 274 | name: 'calc', 275 | args: [ 276 | Expr.BinOp({ 277 | op: '*', 278 | left: Expr.LiteralNumber({ value: 5, unit: 'px' }), 279 | right: Expr.Call({ 280 | name: 'var', 281 | args: [Expr.VarIdentifier('--value')], 282 | }), 283 | }), 284 | ], 285 | }), 286 | ]) 287 | }) 288 | 289 | it('preserves order of operations (same operator)', () => { 290 | expect(parse(`calc(30 - 5 - 3)`)).toEqual([ 291 | Expr.Call({ 292 | name: 'calc', 293 | args: [ 294 | Expr.BinOp({ 295 | op: '-', 296 | left: Expr.BinOp({ 297 | op: '-', 298 | left: Expr.LiteralNumber({ value: 30, unit: '' }), 299 | right: Expr.LiteralNumber({ value: 5, unit: '' }), 300 | }), 301 | right: Expr.LiteralNumber({ value: 3, unit: '' }), 302 | }), 303 | ], 304 | }), 305 | ]) 306 | }) 307 | 308 | it('preserves order of operations (different operators, same precedance)', () => { 309 | expect(parse(`calc(30 + 5 - 3)`)).toEqual([ 310 | Expr.Call({ 311 | name: 'calc', 312 | args: [ 313 | Expr.BinOp({ 314 | op: '-', 315 | left: Expr.BinOp({ 316 | op: '+', 317 | left: Expr.LiteralNumber({ value: 30, unit: '' }), 318 | right: Expr.LiteralNumber({ value: 5, unit: '' }), 319 | }), 320 | right: Expr.LiteralNumber({ value: 3, unit: '' }), 321 | }), 322 | ], 323 | }), 324 | ]) 325 | }) 326 | 327 | it('preserves order of operations (different operators, different precedance)', () => { 328 | expect(parse(`calc(30 / 5 * 3)`)).toEqual([ 329 | Expr.Call({ 330 | name: 'calc', 331 | args: [ 332 | Expr.BinOp({ 333 | op: '*', 334 | left: Expr.BinOp({ 335 | op: '/', 336 | left: Expr.LiteralNumber({ value: 30, unit: '' }), 337 | right: Expr.LiteralNumber({ value: 5, unit: '' }), 338 | }), 339 | right: Expr.LiteralNumber({ value: 3, unit: '' }), 340 | }), 341 | ], 342 | }), 343 | ]) 344 | }) 345 | }) 346 | }) 347 | -------------------------------------------------------------------------------- /tests/signup.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | fireEvent, 3 | waitFor, 4 | getByText, 5 | getByTestId, 6 | prettyDOM, 7 | } from '@testing-library/dom' 8 | import '@testing-library/jest-dom' 9 | import { delay, loadHTMLFixture } from './util' 10 | 11 | describe('signup example', () => { 12 | beforeEach(async () => { 13 | await loadHTMLFixture('signup') 14 | window.fetch = jest.fn() as any 15 | }) 16 | 17 | it('should show form when button is clicked', async () => { 18 | const $showFormBtn = document.getElementById('show-form-btn')! 19 | const $form = document.getElementById('signup-form')! 20 | 21 | expect($showFormBtn).toBeVisible() 22 | expect($showFormBtn.nodeName).toBe('BUTTON') 23 | expect($form).not.toBeVisible() 24 | expect($form.nodeName).toBe('FORM') 25 | 26 | // Click and wait for button to get hidden class (handles delay) 27 | fireEvent.click($showFormBtn) 28 | 29 | await waitFor(() => expect($showFormBtn).not.toBeVisible()) 30 | expect($form).toBeVisible() 31 | }) 32 | 33 | describe('Form submit', () => { 34 | beforeEach(async () => { 35 | const $showFormBtn = document.getElementById('show-form-btn')! 36 | fireEvent.click($showFormBtn) 37 | await waitFor(() => 38 | expect(document.getElementById('signup-form')).toBeVisible(), 39 | ) 40 | await delay(100) // Wait for mounting 41 | }) 42 | 43 | it('should submit form correctly', async () => { 44 | const $form = document.getElementById('signup-form')! 45 | 46 | // Set email and password field 47 | const $email = getByTestId(document.body, 'email') 48 | $email.value = 'no-reply@we-love-replies.com' 49 | const $password = getByTestId(document.body, 'password') 50 | $password.value = 'password' 51 | 52 | await delay(100) 53 | 54 | // Submit form 55 | const $submitBtn = getByText(document.body, 'Submit') 56 | fireEvent.click($submitBtn) 57 | 58 | // Should add submitting class to form 59 | await waitFor(() => 60 | expect($form.classList.contains('submitting')).toBe(true), 61 | ) 62 | expect($submitBtn).toBeDisabled() 63 | 64 | // Should add submitted class to form and remove submitting 65 | await waitFor(() => 66 | expect($form.classList.contains('submitted')).toBe(true), 67 | ) 68 | expect($form.classList.contains('submitting')).toBe(false) 69 | expect($submitBtn).toBeEnabled() 70 | 71 | // Should have made a request to post form data 72 | expect(window.fetch).toHaveBeenCalledTimes(1) 73 | const [url, { method, body }] = (window.fetch as any).mock.calls[0] 74 | expect(url).toBe('http://example.com/submit/api') 75 | expect(method).toBe('POST') 76 | expect(Object.fromEntries(body.entries())).toEqual({ 77 | email: 'no-reply@we-love-replies.com', 78 | password: 'password', 79 | }) 80 | }) 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /tests/todo-app.spec.ts: -------------------------------------------------------------------------------- 1 | import { getByTestId } from '@testing-library/dom' 2 | import '@testing-library/jest-dom' 3 | import { delay, loadHTMLFixture } from './util' 4 | 5 | describe('todo-app example', () => { 6 | describe('Add new task', () => { 7 | let $textInput: HTMLInputElement 8 | let $addBtn: HTMLButtonElement 9 | 10 | let $taskItems: Array = [] 11 | 12 | const submit = async (text: string) => { 13 | $textInput.value = text 14 | $addBtn.click() 15 | 16 | await delay(10) 17 | $taskItems = [ 18 | ...document.querySelectorAll( 19 | '[data-instance="task-item"]', 20 | ), 21 | ] 22 | } 23 | 24 | beforeAll(async () => { 25 | await loadHTMLFixture('todo-app') 26 | 27 | $textInput = getByTestId( 28 | document.body, 29 | 'add-task-input', 30 | ) 31 | $addBtn = getByTestId(document.body, 'add-task-btn') 32 | }) 33 | 34 | it('should add new unchecked task', async () => { 35 | // Add first item 36 | await submit('Buy Milk') 37 | expect($taskItems).toHaveLength(1) 38 | expect(getComputedStyle($taskItems[0]).getPropertyValue('--text')).toBe( 39 | '"Buy Milk"', 40 | ) 41 | 42 | // Add the second item 43 | await submit('Kill all the non-believers') 44 | expect($taskItems).toHaveLength(2) 45 | expect(getComputedStyle($taskItems[0]).getPropertyValue('--text')).toBe( 46 | '"Buy Milk"', 47 | ) 48 | expect(getComputedStyle($taskItems[1]).getPropertyValue('--text')).toBe( 49 | '"Kill all the non-believers"', 50 | ) 51 | }) 52 | 53 | it('should check item when clicked', async () => { 54 | expect( 55 | getComputedStyle($taskItems[0]).getPropertyValue('--checked'), 56 | ).toBe(`false`) 57 | 58 | $taskItems[0].click() 59 | await delay(10) 60 | 61 | expect( 62 | getComputedStyle($taskItems[0]).getPropertyValue('--checked'), 63 | ).toBe(`"true"`) // TODO: look into the quotes issue 64 | 65 | $taskItems[0].click() 66 | await delay(10) 67 | 68 | expect( 69 | getComputedStyle($taskItems[0]).getPropertyValue('--checked'), 70 | ).toBe(`"false"`) // TODO: look into the quotes issue 71 | 72 | $taskItems[0].click() 73 | await delay(10) 74 | 75 | expect( 76 | getComputedStyle($taskItems[0]).getPropertyValue('--checked'), 77 | ).toBe(`"true"`) // TODO: look into the quotes issue 78 | }) 79 | }) 80 | }) 81 | -------------------------------------------------------------------------------- /tests/util.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from 'node:fs/promises' 2 | import { render } from '../src' 3 | 4 | export async function loadHTMLFixture(type: string) { 5 | document.documentElement.innerHTML = await readFile( 6 | `./tests/fixtures/${type}/index.html`, 7 | 'utf8', 8 | ) 9 | await render({ root: document.body }) 10 | } 11 | 12 | export const delay = (delayMs: number) => 13 | new Promise(res => setTimeout(res, delayMs)) 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | "outDir": "dist", 8 | "incremental": true, 9 | "noEmit": false, 10 | "declaration": true, 11 | 12 | "esModuleInterop": true, 13 | "moduleResolution": "bundler", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "react-jsx", 17 | 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"] 24 | } 25 | --------------------------------------------------------------------------------