├── website ├── .gitignore ├── .eslintignore ├── src │ ├── icon-trans.png │ ├── index.ts │ ├── import-png.ts │ └── dynamic │ │ ├── editor.css │ │ ├── editor.ts │ │ ├── blog.article.css │ │ ├── icons.ts │ │ └── blog.article.ts ├── .prettierrc ├── jest.config.js ├── tsconfig.json ├── .eslintrc ├── package.json └── webpack.config.js ├── .gitignore ├── .vscode ├── settings.json └── launch.json ├── jest.config.js ├── .github └── workflows │ ├── size.yml │ ├── website.yml │ └── main.yml ├── __tests__ ├── __snapshots__ │ ├── use-case.spec.ts.snap │ ├── perf.spec.ts.snap │ ├── properties.spec.ts.snap │ ├── update.spec.ts.snap │ ├── render.spec.ts.snap │ └── children.spec.ts.snap ├── use-case.spec.ts ├── properties.spec.ts ├── perf.spec.ts ├── children.spec.ts ├── update.spec.ts └── render.spec.ts ├── README.md ├── LICENSE ├── package.json ├── tsconfig.json └── src └── index.ts /website/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist/ -------------------------------------------------------------------------------- /website/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .vscode/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Prodom" 4 | ] 5 | } -------------------------------------------------------------------------------- /website/src/icon-trans.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ftah/prodom/HEAD/website/src/icon-trans.png -------------------------------------------------------------------------------- /website/src/index.ts: -------------------------------------------------------------------------------- 1 | import blogArticle from './dynamic/blog.article' 2 | 3 | document.body.replaceWith(blogArticle) 4 | -------------------------------------------------------------------------------- /website/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "all", 4 | "singleQuote": true, 5 | "printWidth": 80 6 | } 7 | -------------------------------------------------------------------------------- /website/src/import-png.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png' { 2 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 3 | const value: any 4 | export default value 5 | } 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | globals: { 5 | 'ts-jest': { 6 | isolatedModules: true, 7 | }, 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /website/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | globals: { 5 | 'ts-jest': { 6 | isolatedModules: true, 7 | }, 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /website/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "sourceMap": true, 5 | "noImplicitAny": true, 6 | "module": "es6", 7 | "target": "es5", 8 | "allowJs": true, 9 | "moduleResolution": "node", 10 | "esModuleInterop": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/size.yml: -------------------------------------------------------------------------------- 1 | name: size 2 | on: [pull_request] 3 | jobs: 4 | size: 5 | runs-on: ubuntu-latest 6 | env: 7 | CI_JOB_NUMBER: 1 8 | steps: 9 | - uses: actions/checkout@v1 10 | - uses: andresz1/size-limit-action@v1 11 | with: 12 | github_token: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /website/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint", "prettier"], 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/eslint-recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "prettier" 10 | ], 11 | "rules": { 12 | "no-console": 1, 13 | "prettier/prettier": 2 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Jest Tests", 6 | "type": "node", 7 | "request": "launch", 8 | "runtimeArgs": [ 9 | "--inspect-brk", 10 | "${workspaceRoot}/node_modules/.bin/jest", 11 | "--runInBand" 12 | ], 13 | "console": "integratedTerminal", 14 | "internalConsoleOptions": "neverOpen", 15 | "port": 9229 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/use-case.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`embed inside dom 1`] = ` 4 | 8 | `; 9 | 10 | exports[`embed inside dom by id 1`] = ` 11 | 15 | `; 16 | 17 | exports[`use third-party dom element 1`] = ` 18 |
21 |

22 | child text 23 |

24 | 28 |
29 | `; 30 | -------------------------------------------------------------------------------- /.github/workflows/website.yml: -------------------------------------------------------------------------------- 1 | name: Website 2 | on: push 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | defaults: 7 | run: 8 | working-directory: website 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Install modules 12 | run: yarn 13 | - name: Build website 14 | run: yarn build 15 | - name: Deploy website 16 | uses: peaceiris/actions-gh-pages@v3 17 | # if: github.ref == 'refs/heads/master' 18 | with: 19 | github_token: ${{ secrets.GITHUB_TOKEN }} 20 | publish_dir: ./website/dist 21 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 6 | 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | node: ['10.x', '12.x', '14.x'] 11 | os: [ubuntu-latest] 12 | 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v2 16 | 17 | - name: Use Node ${{ matrix.node }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node }} 21 | 22 | - name: Install deps and build (with cache) 23 | uses: bahmutov/npm-install@v1 24 | 25 | - name: Lint 26 | run: yarn lint 27 | 28 | - name: Test 29 | run: yarn test --ci --coverage --maxWorkers=2 30 | 31 | - name: Build 32 | run: yarn build 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Prodom ![Build](https://github.com/m3ftah/prodom/actions/workflows/main.yml/badge.svg) [![MinZipped Size](https://img.shields.io/bundlephobia/minzip/prodom?label=gzipped&style=flat&colorA=000000&colorB=000000)](https://bundlephobia.com/result?p=prodom) [![Version](https://img.shields.io/npm/v/prodom?style=flat&colorA=000000&colorB=000000)](https://www.npmjs.com/package/prodom) [![Downloads](https://img.shields.io/npm/dt/prodom.svg?style=flat&colorA=000000&colorB=000000)](https://www.npmjs.com/package/prodom) [![Downloads](https://badgen.net/npm/license/prodom)](https://badgen.net/npm/license/prodom) 2 | 3 | This is a declarative UI web framework. 4 | 5 | ## Example 6 | 7 | ```js 8 | const prototype = { 9 | tag: 'div', 10 | textContent: 'Hello world', 11 | }; 12 | render(prototype, {}); 13 | ``` 14 | 15 | ## Live example 16 | 17 | [Edit on CodePen](https://codepen.io/pen/?template=PoppQMM) 18 | 19 | ## More information 20 | 21 | [Website](https://m3ftah.github.io/prodom) 22 | 23 | ## License 24 | 25 | MIT 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Lakhdar Meftah 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. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.0.4", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "files": [ 7 | "dist", 8 | "src" 9 | ], 10 | "engines": { 11 | "node": ">=10" 12 | }, 13 | "scripts": { 14 | "start": "tsdx watch", 15 | "build": "tsdx build", 16 | "test": "tsdx test", 17 | "lint": "tsdx lint src", 18 | "prepare": "tsdx build", 19 | "size": "size-limit", 20 | "analyze": "size-limit --why" 21 | }, 22 | "peerDependencies": {}, 23 | "husky": { 24 | "hooks": { 25 | "pre-commit": "tsdx lint src" 26 | } 27 | }, 28 | "prettier": { 29 | "printWidth": 80, 30 | "semi": true, 31 | "singleQuote": true, 32 | "trailingComma": "es5" 33 | }, 34 | "name": "prodom", 35 | "author": "Lakhdar Meftah ", 36 | "module": "dist/prodom.esm.js", 37 | "size-limit": [ 38 | { 39 | "path": "dist/prodom.cjs.production.min.js", 40 | "limit": "2 KB" 41 | }, 42 | { 43 | "path": "dist/prodom.esm.js", 44 | "limit": "2 KB" 45 | } 46 | ], 47 | "devDependencies": { 48 | "@size-limit/preset-small-lib": "^4.10.3", 49 | "husky": "^6.0.0", 50 | "size-limit": "^4.10.3", 51 | "tsdx": "^0.14.1", 52 | "tslib": "^2.2.0", 53 | "typescript": "^3.9.9" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/perf.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`multiple blocking store render 1`] = ` 4 |
5 | 8 | 1 9 | 10 | 13 | 16 |
17 | `; 18 | 19 | exports[`multiple blocking store render with chunks 1`] = ` 20 |
21 | 24 | 1 25 | 26 | 29 | 32 |
33 | Hello 34 |
35 |
36 | Hello 37 |
38 |
39 | Hello 40 |
41 |
42 | Hello 43 |
44 |
45 | Hello 46 |
47 |
48 | Hello 49 |
50 |
51 | Hello 52 |
53 |
54 | Hello 55 |
56 |
57 | Hello 58 |
59 |
60 | Hello 61 |
62 |
63 | Hello 64 |
65 |
66 | Hello 67 |
68 |
69 | Hello 70 |
71 |
72 | Hello 73 |
74 |
75 | Hello 76 |
77 |
78 | Hello 79 |
80 |
81 | Hello 82 |
83 |
84 | Hello 85 |
86 |
87 | Hello 88 |
89 |
90 | Hello 91 |
92 |
93 | `; 94 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prodom-documentation", 3 | "version": "1.0.0", 4 | "description": "Prodom is a declarative dom framework", 5 | "private": true, 6 | "sideEffects": [ 7 | "*.css" 8 | ], 9 | "scripts": { 10 | "start": "webpack serve --host 0.0.0.0", 11 | "build": "webpack --env production", 12 | "lint": "eslint . --ext .ts", 13 | "lint-and-fix": "eslint . --ext .ts --fix", 14 | "prettier-format": "prettier --config .prettierrc 'src/**/*.ts' --write", 15 | "watch": "webpack --watch", 16 | "test": "jest", 17 | "test-watch": "jest --watch" 18 | }, 19 | "keywords": [], 20 | "author": "", 21 | "license": "ISC", 22 | "devDependencies": { 23 | "@types/jest": "^26.0.23", 24 | "@typescript-eslint/eslint-plugin": "^4.15.2", 25 | "@typescript-eslint/parser": "^4.15.2", 26 | "clean-webpack-plugin": "^3.0.0", 27 | "css-loader": "^5.0.2", 28 | "eslint": "^7.20.0", 29 | "eslint-config-prettier": "^8.1.0", 30 | "eslint-plugin-jest": "^24.3.6", 31 | "eslint-plugin-prettier": "^3.3.1", 32 | "html-webpack-plugin": "^5.2.0", 33 | "jest": "^26.6.3", 34 | "prettier": "^2.2.1", 35 | "source-map-loader": "^3.0.0", 36 | "style-loader": "^2.0.0", 37 | "ts-jest": "^26.5.6", 38 | "ts-loader": "^8.0.17", 39 | "typescript": "^4.2.2", 40 | "webpack": "^5.24.1", 41 | "webpack-cli": "^4.5.0", 42 | "webpack-dev-server": "^3.11.2", 43 | "webpack-pwa-manifest": "^4.3.0" 44 | }, 45 | "dependencies": { 46 | "prodom": "^0.0.4" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | // output .d.ts declaration files for consumers 9 | "declaration": true, 10 | // output .js.map sourcemap files for consumers 11 | "sourceMap": true, 12 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 13 | "rootDir": "./src", 14 | // stricter type-checking for stronger correctness. Recommended by TS 15 | // "strict": true, 16 | // linter checks for common issues 17 | //"noImplicitReturns": true, 18 | //"noFallthroughCasesInSwitch": true, 19 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 20 | //"noUnusedLocals": true, 21 | //"noUnusedParameters": true, 22 | // use Node's module resolution algorithm, instead of the legacy TS one 23 | "moduleResolution": "node", 24 | // interop between ESM and CJS modules. Recommended by TS 25 | "esModuleInterop": true, 26 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 27 | "skipLibCheck": true, 28 | // error out if import and file system have a casing mismatch. Recommended by TS 29 | "forceConsistentCasingInFileNames": true, 30 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 31 | "noEmit": true 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/properties.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`freeze property 1`] = ` 4 |

5 | child text 6 |

7 | `; 8 | 9 | exports[`init property 1`] = ` 10 |
13 |

16 | some text 17 |

18 |
19 | `; 20 | 21 | exports[`init property 2`] = ` 22 | 25 | `; 26 | 27 | exports[`init property 3`] = ` 28 | 31 | `; 32 | 33 | exports[`setAttribute property 1`] = ` 34 | 37 | other input 38 | 39 | `; 40 | 41 | exports[`setAttribute property 2`] = ` 42 | 46 | other input 47 | 48 | `; 49 | 50 | exports[`setAttribute property 3`] = ` 51 | 55 | other input 56 | 57 | `; 58 | 59 | exports[`setAttribute property 4`] = ` 60 | 65 | other input 66 | 67 | `; 68 | 69 | exports[`setAttribute property 5`] = ` 70 | 75 | other input 76 | 77 | `; 78 | 79 | exports[`tag property 1`] = ` 80 |
83 |

86 | some text 87 |

88 | 91 | other input 92 | 93 |
94 | `; 95 | 96 | exports[`virtual property 1`] = ` 97 |
98 | 99 | Hello world 100 | 101 |
102 | `; 103 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/update.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`remove all classNames 1`] = ` 4 |

7 | modified by diff 8 |

9 | `; 10 | 11 | exports[`remove one className 1`] = ` 12 |

15 | modified by diff 16 |

17 | `; 18 | 19 | exports[`update children content and type 1`] = ` 20 |
23 | 27 | child 1 28 | 29 | 32 | child 2 33 | 34 |
35 | `; 36 | 37 | exports[`update children content and type 2`] = ` 38 |
41 |

44 | modified for new item 45 |

46 | 49 | modified by diff 50 | 51 |
52 | `; 53 | 54 | exports[`update pure component 1`] = ` 55 | 58 | Hello world 59 | 60 | `; 61 | 62 | exports[`update pure component 2`] = ` 63 | 66 | Updated text 67 | 68 | `; 69 | 70 | exports[`update pure component 3`] = ` 71 | 74 | Hello world 75 | 76 | `; 77 | 78 | exports[`update style 1`] = ` 79 |

83 | modified by diff 84 |

85 | `; 86 | 87 | exports[`update style 2`] = ` 88 |

92 | modified by diff 93 |

94 | `; 95 | 96 | exports[`update style 3`] = ` 97 |

101 | modified by diff 102 |

103 | `; 104 | 105 | exports[`update textContent 1`] = ` 106 |

107 | modified by diff 108 |

109 | `; 110 | -------------------------------------------------------------------------------- /__tests__/use-case.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import { Prototype, render } from '../src'; 5 | 6 | test('embed inside dom', () => { 7 | const childID = 'profileNameDOM'; 8 | const className1 = 'activated'; 9 | const containerDOM = document.createElement('div'); 10 | const prototype = { 11 | tag: 'span', 12 | id: childID, 13 | className: [className1], 14 | }; 15 | const receivedDOM = render(prototype, {}); 16 | containerDOM.append(receivedDOM); 17 | expect(containerDOM.children[0].id).toBe(childID); 18 | expect(containerDOM.children[0].classList).toContain(className1); 19 | expect(receivedDOM).toMatchSnapshot(); 20 | }); 21 | 22 | test('embed inside dom by id', () => { 23 | const childID = 'profileNameDOM'; 24 | const className1 = 'activated'; 25 | const containerDOM = document.createElement('div'); 26 | const profileNameDOM = document.createElement('span'); 27 | profileNameDOM.id = childID; 28 | containerDOM.append(profileNameDOM); 29 | const prototype: Prototype = { 30 | init: () => containerDOM.querySelector('#' + childID), 31 | className: [className1], 32 | }; 33 | const receivedDOM = render(prototype, {}); 34 | expect(receivedDOM.id).toBe(childID); 35 | expect(receivedDOM).toMatchSnapshot(); 36 | }); 37 | 38 | test('use third-party dom element', () => { 39 | const expectedText1 = 'child text'; 40 | const imageSRC = 'https://i.picsum.photos/id/320/200/300.jpg'; 41 | const className1 = 'selected'; 42 | const context = {}; 43 | const generateImageDOM = (src: string) => { 44 | const imageDOM = document.createElement('img'); 45 | imageDOM.src = src; 46 | return imageDOM; 47 | }; 48 | const receivedDOM = render( 49 | { 50 | tag: 'div', 51 | className: ['highlighted'], 52 | children: [ 53 | { tag: 'p', textContent: expectedText1 }, 54 | { 55 | init: () => generateImageDOM(imageSRC), 56 | className: [className1], 57 | }, 58 | ], 59 | } as Prototype, 60 | context 61 | ); 62 | expect((receivedDOM.children[0] as HTMLParagraphElement).textContent).toBe( 63 | expectedText1 64 | ); 65 | expect((receivedDOM.children[1] as HTMLImageElement).classList).toContain( 66 | className1 67 | ); 68 | expect((receivedDOM.children[1] as HTMLImageElement).src).toBe(imageSRC); 69 | expect(receivedDOM).toMatchSnapshot(); 70 | }); 71 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/render.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`render async pure component 1`] = ` 4 |
5 | 8 | 1 9 | 10 | 13 | 16 |
17 | `; 18 | 19 | exports[`render async pure component 2`] = ` 20 |
21 | 24 | 2, was: 1 25 | 26 | 29 | 32 |
33 | `; 34 | 35 | exports[`render component 1`] = ` 36 |
37 | 38 | Mike 39 | 40 | 43 |
44 | `; 45 | 46 | exports[`render contextual component 1`] = ` 47 |
48 | 49 | Mike 50 | 51 |
53 | `; 54 | 55 | exports[`render contextual component 2`] = ` 56 |
57 | 58 | Max 59 | 60 |
62 | `; 63 | 64 | exports[`render one child 1`] = ` 65 |
66 |

67 | child text 68 |

69 |
70 | `; 71 | 72 | exports[`render pure component 1`] = ` 73 |
74 | 77 | 1 78 | 79 | 82 | 85 |
86 | `; 87 | 88 | exports[`render pure component 2`] = ` 89 |
90 | 93 | 2, was: 1 94 | 95 | 98 | 101 |
102 | `; 103 | 104 | exports[`render store 1`] = ` 105 |
106 | 109 | 1 110 | 111 | 114 | 117 |
118 | `; 119 | 120 | exports[`render store 2`] = ` 121 |
122 | 125 | 2 126 | 127 | 130 | 133 |
134 | `; 135 | 136 | exports[`render two children 1`] = ` 137 |
140 | 144 | child 1 145 | 146 | 149 | child 2 150 | 151 |
152 | `; 153 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/children.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`move one keyed child - keep pure 1`] = ` 4 |
7 | 11 | child 1 12 | 13 | 16 | child 2 17 | 18 | 21 | child 3 22 | 23 |
24 | `; 25 | 26 | exports[`move one keyed child - keep pure 2`] = ` 27 |
30 | 34 | child 1 35 | 36 | 39 | child 3 40 | 41 | 44 | child 2 45 | 46 |
47 | `; 48 | 49 | exports[`move one keyed child - keep pure 3`] = ` 50 |
53 | 56 | child 3 57 | 58 | 62 | child 1 63 | 64 |
65 | `; 66 | 67 | exports[`remove one child 1`] = ` 68 |
71 | 75 | child 1 76 | 77 | 80 | child 2 81 | 82 | 85 | child 3 86 | 87 |
88 | `; 89 | 90 | exports[`remove one child 2`] = ` 91 |
94 | 98 | child 1 99 | 100 | 103 | child 3 104 | 105 |
106 | `; 107 | 108 | exports[`render with condition 1`] = ` 109 |
112 |

115 | some text 116 |

117 | 120 | other input 121 | 122 |
123 | `; 124 | 125 | exports[`render with condition 2`] = ` 126 |
129 | 132 | other input 133 | 134 |
135 | `; 136 | 137 | exports[`render with condition 3`] = ` 138 |
141 |

144 | some text 145 |

146 | 149 | other input 150 | 151 |
152 | `; 153 | -------------------------------------------------------------------------------- /website/src/dynamic/editor.css: -------------------------------------------------------------------------------- 1 | .editor-container { 2 | padding: 4px 16px 16px 16px; 3 | border-radius: 4px; 4 | box-shadow: 2px 2px 8px lightgray; 5 | box-sizing: border-box; 6 | margin-bottom: 40px; 7 | } 8 | .editor-container d { 9 | background-color: #eee; 10 | border-width: 1px; 11 | border-color: red; 12 | border-style: dashed; 13 | } 14 | .editor-container:hover { 15 | box-shadow: 2px 2px 8px 2px lightgray; 16 | } 17 | .editor-container .header { 18 | display: flex; 19 | flex-direction: row; 20 | padding: 8px 8px 8px 8px; 21 | font-size: 1.5rem; 22 | justify-content: space-between; 23 | } 24 | .editor-container .title { 25 | padding: 0px 8px 8px 8px; 26 | font-size: 1.5rem; 27 | align-self: center; 28 | } 29 | 30 | .editor-container .record { 31 | display: inline-flex; 32 | border-radius: 32px; 33 | cursor: pointer; 34 | box-shadow: 2px 2px 8px lightgray; 35 | } 36 | .editor-container .record:hover { 37 | border-radius: 32px; 38 | box-shadow: 2px 2px 8px 2px lightgray; 39 | } 40 | 41 | .editor-container .recording { 42 | background-color: lightgray; 43 | } 44 | 45 | .demo-container { 46 | display: flex; 47 | flex-direction: row; 48 | flex-wrap: wrap; 49 | flex: 1 0 auto; 50 | word-break: break-all; 51 | align-content: space-between; 52 | } 53 | 54 | .demo-container .left-demo { 55 | font-size: 1rem; 56 | flex-basis: 600px; 57 | padding: 8px; 58 | min-height: 150px; 59 | border-radius: 4px; 60 | overflow-x: scroll; 61 | overflow-y: hidden; 62 | border: 1px solid #ccc; 63 | margin: 4px; 64 | white-space: pre; 65 | } 66 | 67 | .demo-container .right-demo { 68 | display: flex; 69 | flex-basis: 600px; 70 | 71 | align-self: stretch; 72 | font-size: 1rem; 73 | padding: 8px; 74 | border-color: lightgrey; 75 | border-width: 1px; 76 | border-style: solid; 77 | border-radius: 4px; 78 | word-break: break-all; 79 | overflow: hidden; 80 | margin: 4px; 81 | } 82 | @media only screen and (min-width: 600px) { 83 | .demo-container .left-demo { 84 | flex: 1; 85 | } 86 | .demo-container .right-demo { 87 | flex: 1; 88 | } 89 | } 90 | 91 | .tooltip { 92 | position: relative; 93 | cursor: pointer; 94 | border-radius: 4px; 95 | box-shadow: 2px 2px 8px lightgray; 96 | } 97 | 98 | .tooltip .tooltiptext { 99 | visibility: hidden; 100 | display: flex; 101 | background-color: black; 102 | color: #fff; 103 | text-align: center; 104 | border-radius: 4px; 105 | padding: 8px; 106 | box-shadow: 2px 2px 8px 2px lightgray; 107 | 108 | /* Position the tooltip */ 109 | position: absolute; 110 | z-index: 1; 111 | top: 100%; 112 | left: 50%; 113 | margin-left: -60px; 114 | } 115 | 116 | .tooltip:hover .tooltiptext { 117 | visibility: visible; 118 | } 119 | 120 | .tooltip:hover { 121 | box-shadow: 2px 2px 8px 2px lightgray; 122 | } 123 | -------------------------------------------------------------------------------- /website/src/dynamic/editor.ts: -------------------------------------------------------------------------------- 1 | import { buildStore as buildIt, inferType, Prototype } from 'prodom' 2 | import './editor.css' 3 | import { devIcon } from './icons' 4 | export interface EditorProps { 5 | demo: string 6 | title: string 7 | } 8 | 9 | const Editor = ( 10 | { demo, title }: EditorProps, 11 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 12 | { onDemoChange, setTitle }: EditorActions, 13 | demoProp: string, 14 | titleProp: string, 15 | link: string, 16 | devMode: boolean, 17 | dark: boolean, 18 | ) => { 19 | const resolvedDemo = demo !== undefined ? demo : demoProp 20 | const resolvedTitle = title !== undefined ? title : titleProp 21 | const buildStore = buildIt 22 | const titleDOM = { 23 | tag: 'div', 24 | className: ['title', devMode && 'dev', dark && 'dark'], 25 | innerText: resolvedTitle, 26 | contentEditable: devMode, 27 | } 28 | 29 | const linkDOM = { 30 | tag: 'div', 31 | className: ['title', 'tooltip', devMode && 'dev', dark && 'dark'], 32 | children: [ 33 | { tag: 'span', innerText: 'edit on codepen', className: ['tooltiptext'] }, 34 | { 35 | tag: 'a', 36 | href: link, 37 | className: ['link'], 38 | children: [devIcon(dark, '#666', '#bbb', 16, 16)], 39 | }, 40 | ], 41 | contentEditable: devMode, 42 | } 43 | 44 | const headerDOM: Prototype = { 45 | tag: 'div', 46 | className: ['header', devMode && 'dev', dark && 'dark'], 47 | children: [titleDOM, link && linkDOM], 48 | contentEditable: devMode, 49 | } 50 | 51 | const leftDemoDOM: Prototype = { 52 | tag: 'div', 53 | className: ['left-demo', devMode && 'dev', dark && 'dark'], 54 | innerText: demoProp, 55 | oninput: (e: Event) => { 56 | onDemoChange((e.target as HTMLDivElement).innerText) 57 | }, 58 | contentEditable: 'true', 59 | } 60 | const rightDemoDOM = inferType({ 61 | tag: 'div', 62 | className: ['right-demo', devMode && 'dev', dark && 'dark'], 63 | children: [eval('(' + resolvedDemo + ')')], 64 | contentEditable: devMode, 65 | }) 66 | 67 | const demoContainerDOM: Prototype = { 68 | tag: 'div', 69 | className: ['demo-container', devMode && 'dev', dark && 'dark'], 70 | children: [leftDemoDOM, rightDemoDOM], 71 | contentEditable: devMode, 72 | } 73 | return { 74 | tag: 'div', 75 | className: ['editor-container', devMode && 'dev', dark && 'dark'], 76 | children: [headerDOM, demoContainerDOM], 77 | contentEditable: devMode, 78 | } 79 | } 80 | 81 | type EditorActions = { 82 | onDemoChange: (demo: string) => void 83 | setTitle: (title: string) => void 84 | } 85 | 86 | const actions = (state: EditorProps): EditorActions => ({ 87 | onDemoChange: (demo: string) => { 88 | state.demo = demo 89 | }, 90 | setTitle: (title: string) => { 91 | state.title = title 92 | }, 93 | }) 94 | 95 | export default buildIt(Editor, actions) 96 | -------------------------------------------------------------------------------- /website/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const HtmlWebpackPlugin = require('html-webpack-plugin') 3 | const { CleanWebpackPlugin } = require('clean-webpack-plugin') 4 | const WebpackPwaManifest = require('webpack-pwa-manifest') 5 | 6 | module.exports = function (env) { 7 | return { 8 | mode: env.production ? 'production' : 'development', 9 | devtool: env.production ? 'source-map' : undefined, 10 | entry: './src/index.ts', 11 | devServer: { 12 | contentBase: './dist', 13 | disableHostCheck: true, 14 | // hot: true, 15 | }, 16 | plugins: [ 17 | new CleanWebpackPlugin({ cleanStaleWebpackAssets: false }), 18 | new HtmlWebpackPlugin({ 19 | title: 'Prodom, the next open web app framework', 20 | favicon: './src/icon-trans.png', 21 | }), 22 | new WebpackPwaManifest({ 23 | name: 'Prodom introduction', 24 | short_name: 'Prodom', 25 | description: 'A simple introduction into the Prodom framework', 26 | background_color: '#ffffff', 27 | fingerprints: false, 28 | crossorigin: 'anonymous', //can be null, use-credentials or anonymous 29 | icons: [ 30 | { 31 | src: path.resolve('src/icon-trans.png'), 32 | sizes: [96, 128, 192, 256, 384, 512], // multiple sizes 33 | }, 34 | ], 35 | }), 36 | ], 37 | output: { 38 | publicPath: '', 39 | filename: '[name].[contenthash].js', 40 | path: path.resolve(__dirname, 'dist'), 41 | }, 42 | optimization: { 43 | moduleIds: 'deterministic', 44 | runtimeChunk: 'single', 45 | splitChunks: { 46 | cacheGroups: { 47 | vendor: { 48 | test: /[\\/]node_modules[\\/]/, 49 | name: 'vendors', 50 | chunks: 'all', 51 | }, 52 | }, 53 | }, 54 | }, 55 | resolve: { 56 | extensions: ['.ts', '.js'], 57 | }, 58 | module: { 59 | rules: [ 60 | { 61 | test: /\.ts?$/, 62 | use: [ 63 | { 64 | loader: 'ts-loader', 65 | options: { 66 | transpileOnly: true, 67 | }, 68 | }, 69 | ], 70 | include: path.resolve(__dirname, 'src'), 71 | exclude: /node_modules/, 72 | }, 73 | { 74 | test: /\.css$/i, 75 | include: path.resolve(__dirname, 'src'), 76 | use: ['style-loader', 'css-loader'], 77 | }, 78 | { 79 | test: /\.(png|svg|jpg|jpeg|gif)$/i, 80 | include: path.resolve(__dirname, 'src'), 81 | type: 'asset/resource', 82 | }, 83 | { 84 | test: /\.(woff|woff2|eot|ttf|otf)$/i, 85 | include: path.resolve(__dirname, 'src'), 86 | type: 'asset/resource', 87 | }, 88 | { 89 | test: /\.js$/, 90 | enforce: 'pre', 91 | use: ['source-map-loader'], 92 | }, 93 | ], 94 | }, 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /website/src/dynamic/blog.article.css: -------------------------------------------------------------------------------- 1 | .blog-article-page { 2 | display: flex; 3 | flex-direction: column; 4 | font-size: 1rem; 5 | } 6 | .header { 7 | align-self: flex-end; 8 | margin-bottom: 16px; 9 | } 10 | .control { 11 | display: inline-flex; 12 | cursor: pointer; 13 | align-self: flex-end; 14 | border-radius: 4px; 15 | box-shadow: 2px 2px 8px lightgray; 16 | box-sizing: border-box; 17 | padding: 8px; 18 | margin-right: 8px; 19 | } 20 | .control:hover { 21 | box-shadow: 2px 2px 8px 2px lightgray; 22 | } 23 | .blog-article-container { 24 | display: flex; 25 | flex: 1; 26 | flex-direction: column; 27 | align-self: center; 28 | position: relative; 29 | padding: 16px; 30 | width: 100%; 31 | border-bottom: 1px dashed blue; 32 | box-sizing: border-box; 33 | } 34 | @media only screen and (min-width: 600px) { 35 | .blog-article-container { 36 | width: 80%; 37 | } 38 | } 39 | .blog-article-container .blog-article-title { 40 | font-size: 2rem; 41 | font-weight: bold; 42 | padding: 16px 16px 0 16px; 43 | margin: 0; 44 | } 45 | .blog-article-container .blog-article-subtitle { 46 | font-size: 1.5rem; 47 | font-style: italic; 48 | padding: 4px 16px 0px 16px; 49 | margin: 0; 50 | } 51 | .blog-article-container .blog-article-link { 52 | font-size: 1.2rem; 53 | font-style: italic; 54 | padding: 4px 16px 8px 16px; 55 | margin: 0; 56 | } 57 | .blog-article-container .blog-article-date { 58 | font-size: 1rem; 59 | font-style: italic; 60 | font-weight: bold; 61 | padding: 0 16px 0 16px; 62 | margin: 0; 63 | color: #888; 64 | } 65 | .blog-article-container .blog-article-date.dark { 66 | color: #fff; 67 | } 68 | .blog-article-container .blog-article-body { 69 | font-size: 1rem; 70 | padding: 16px; 71 | margin-bottom: 16px; 72 | line-height: 2rem; 73 | } 74 | .blog-article-container code { 75 | font-size: 1rem; 76 | background-color: #eee; 77 | padding: 2px; 78 | color: #95c; 79 | border-radius: 4px; 80 | border-width: 1px; 81 | border-color: #bbb; 82 | border-style: solid; 83 | } 84 | .dev { 85 | background-color: #eee; 86 | border-width: 1px; 87 | border-color: red; 88 | border-style: dashed; 89 | } 90 | .row { 91 | display: flex; 92 | flex-direction: row; 93 | padding: 8px; 94 | } 95 | .rowItem { 96 | margin-left: 8px; 97 | padding: 8px; 98 | } 99 | .grow { 100 | flex: 1; 101 | } 102 | .blog-article-container .blog-article-image { 103 | width: 100px; 104 | } 105 | .bold { 106 | font-weight: bold; 107 | } 108 | .italic { 109 | font-style: italic; 110 | } 111 | .none { 112 | display: none; 113 | } 114 | .click { 115 | display: inline-block; 116 | padding: 16; 117 | } 118 | .click:hover { 119 | cursor: pointer; 120 | background-color: #eee; 121 | border-radius: 16px; 122 | } 123 | 124 | .colored { 125 | background-color: #fde; 126 | } 127 | 128 | .padding { 129 | height: 200px; 130 | } 131 | 132 | code.dark, 133 | h1.dark, 134 | h2.dark, 135 | p.dark, 136 | .dark { 137 | background-color: #222; 138 | color: #fff; 139 | } 140 | -------------------------------------------------------------------------------- /website/src/dynamic/icons.ts: -------------------------------------------------------------------------------- 1 | import { Prototype } from 'prodom' 2 | 3 | const svgNS = 'http://www.w3.org/2000/svg' 4 | 5 | export const darkIcon = ( 6 | dark: boolean, 7 | darkColor?: string, 8 | lightColor?: string, 9 | ): Prototype => { 10 | return { 11 | init: () => document.createElementNS(svgNS, 'svg'), 12 | setAttribute: { 13 | width: '48', 14 | height: '48', 15 | viewBox: '0 0 100 100', 16 | fill: 'none', 17 | }, 18 | children: [ 19 | { 20 | init: () => document.createElementNS(svgNS, 'circle'), 21 | setAttribute: { 22 | cx: '50', 23 | cy: '50', 24 | r: '45', 25 | fill: dark ? '#000' : '#FFF', 26 | stroke: dark ? '#000' : '#FFF', 27 | }, 28 | }, 29 | { 30 | init: () => document.createElementNS(svgNS, 'circle'), 31 | setAttribute: { 32 | cx: '50', 33 | cy: '50', 34 | r: '40', 35 | fill: dark ? lightColor : '#FFF', 36 | }, 37 | }, 38 | { 39 | init: () => document.createElementNS(svgNS, 'path'), 40 | setAttribute: { 41 | d: `M 50 10 A 40 40 0 0 0 50 90`, 42 | fill: dark ? '#000' : darkColor, 43 | stroke: dark ? '#000' : darkColor, 44 | }, 45 | }, 46 | { 47 | init: () => document.createElementNS(svgNS, 'circle'), 48 | setAttribute: { 49 | cx: '50', 50 | cy: '50', 51 | r: '20', 52 | fill: dark ? lightColor : '#FFF', 53 | }, 54 | }, 55 | { 56 | init: () => document.createElementNS(svgNS, 'path'), 57 | setAttribute: { 58 | d: `M 50 30 A 20 20 0 0 1 50 70`, 59 | fill: dark ? '#000' : darkColor, 60 | stroke: dark ? '#000' : darkColor, 61 | }, 62 | }, 63 | ], 64 | } 65 | } 66 | 67 | export const devIcon = ( 68 | dark: boolean, 69 | darkColor?: string, 70 | lightColor?: string, 71 | width?: number, 72 | height?: number, 73 | ): Prototype => { 74 | return { 75 | init: () => document.createElementNS(svgNS, 'svg'), 76 | setAttribute: { 77 | width: width !== undefined ? '' + width : '48', 78 | height: height !== undefined ? '' + height : '48', 79 | viewBox: '0 0 100 100', 80 | fill: 'none', 81 | }, 82 | children: [ 83 | { 84 | init: () => document.createElementNS(svgNS, 'path'), 85 | setAttribute: { 86 | d: `M 25 25 L 0 50, 25 75, 35 65, 20 50, 35 35 Z`, 87 | fill: dark ? lightColor : darkColor, 88 | stroke: dark ? lightColor : darkColor, 89 | }, 90 | }, 91 | { 92 | init: () => document.createElementNS(svgNS, 'path'), 93 | setAttribute: { 94 | d: `M 35 95 L 55 2, 65 5, 45 98 Z`, 95 | fill: dark ? lightColor : darkColor, 96 | stroke: dark ? lightColor : darkColor, 97 | }, 98 | }, 99 | { 100 | init: () => document.createElementNS(svgNS, 'path'), 101 | setAttribute: { 102 | d: `M 75 25 L 100 50, 75 75, 65 65, 80 50, 65 35 Z`, 103 | fill: dark ? lightColor : darkColor, 104 | stroke: dark ? lightColor : darkColor, 105 | }, 106 | }, 107 | ], 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /__tests__/properties.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import { Prototype, render } from '../src'; 5 | 6 | test('init property', () => { 7 | const context = {}; 8 | const mockable = jest.fn(() => 0); 9 | const receivedDOM = render( 10 | { 11 | init: () => { 12 | mockable(); 13 | return document.createElement('div'); 14 | }, 15 | className: ['form-item'], 16 | children: [ 17 | { 18 | tag: 'p', 19 | textContent: 'some text', 20 | className: ['form'], 21 | } as Prototype, 22 | ], 23 | }, 24 | context 25 | ); 26 | expect(mockable.mock.calls.length).toBe(1); 27 | expect(receivedDOM.tagName).toBe('DIV'); 28 | expect(receivedDOM.children[0].tagName).toBe('P'); 29 | expect(receivedDOM).toMatchSnapshot(); 30 | 31 | const existingDOM1 = document.createElement('input'); 32 | const classList1 = 'form-item'; 33 | const receivedDOM2 = render( 34 | { 35 | init: () => { 36 | mockable(); 37 | return existingDOM1; 38 | }, 39 | className: [classList1], 40 | }, 41 | context 42 | ); 43 | expect(mockable.mock.calls.length).toBe(2); 44 | expect(receivedDOM2.tagName).toBe('INPUT'); 45 | expect(receivedDOM2.classList).toContain(classList1); 46 | expect(receivedDOM2).toMatchSnapshot(); 47 | 48 | render( 49 | { 50 | init: () => { 51 | mockable(); 52 | return existingDOM1; 53 | }, 54 | className: ['hi'], 55 | }, 56 | context 57 | ); 58 | expect(mockable.mock.calls.length).toBe(2); 59 | expect(receivedDOM2.classList).not.toContain(classList1); 60 | expect(receivedDOM2).toMatchSnapshot(); 61 | }); 62 | 63 | test('tag property', () => { 64 | const receivedDOM = render( 65 | { 66 | tag: 'div', 67 | className: ['form-item'], 68 | children: [ 69 | { 70 | tag: 'p', 71 | textContent: 'some text', 72 | className: ['form'], 73 | } as Prototype, 74 | { 75 | tag: 'input', 76 | textContent: 'other input', 77 | className: ['selected'], 78 | } as Prototype, 79 | ], 80 | }, 81 | {} 82 | ); 83 | expect(receivedDOM.tagName).toBe('DIV'); 84 | expect(receivedDOM.children[0].tagName).toBe('P'); 85 | expect(receivedDOM.children[1].tagName).toBe('INPUT'); 86 | expect(receivedDOM).toMatchSnapshot(); 87 | }); 88 | 89 | test('virtual property', () => { 90 | const childID = 'profileNameDOM'; 91 | const className1 = 'activated'; 92 | const textContext1 = 'Hello world'; 93 | const containerDOM = document.createElement('div'); 94 | const profileNameDOM = document.createElement('span'); 95 | profileNameDOM.id = childID; 96 | containerDOM.append(profileNameDOM); 97 | const prototype: Prototype = { 98 | tag: 'div', 99 | children: [ 100 | { 101 | init: () => containerDOM.querySelector('#' + childID), 102 | className: [className1], 103 | virtual: true, 104 | }, 105 | { 106 | tag: 'span', 107 | textContent: textContext1, 108 | }, 109 | ], 110 | }; 111 | const receivedDOM = render(prototype, {}); 112 | expect(receivedDOM.children.length).toBe(1); 113 | expect(containerDOM.children.length).toBe(1); 114 | expect(containerDOM.children[0].id).toBe(childID); 115 | expect(containerDOM.children[0].classList).toContain(className1); 116 | expect(receivedDOM.children[0].textContent).toBe(textContext1); 117 | expect(receivedDOM).toMatchSnapshot(); 118 | }); 119 | 120 | test('freeze property', () => { 121 | const expectedText1 = 'child text'; 122 | const expectedText2 = 'modified by diff'; 123 | const context = {}; 124 | const receivedDOM = render( 125 | { tag: 'p', textContent: expectedText1 } as Prototype, 126 | context 127 | ); 128 | expect((receivedDOM as HTMLParagraphElement).textContent).toBe(expectedText1); 129 | render( 130 | { 131 | tag: 'p', 132 | textContent: expectedText2, 133 | freeze: true, 134 | } as Prototype, 135 | context 136 | ); 137 | expect((receivedDOM as HTMLParagraphElement).textContent).toBe(expectedText1); 138 | expect(receivedDOM).toMatchSnapshot(); 139 | }); 140 | 141 | test('setAttribute property', () => { 142 | const context = {}; 143 | const receivedDOM = render( 144 | { 145 | tag: 'input', 146 | textContent: 'other input', 147 | className: ['selected'], 148 | } as Prototype, 149 | context 150 | ); 151 | expect(receivedDOM.tagName).toBe('INPUT'); 152 | const defaultType = receivedDOM.type; 153 | expect(receivedDOM).toMatchSnapshot(); 154 | 155 | render( 156 | { 157 | tag: 'input', 158 | setAttribute: { 159 | type: 'radio', 160 | }, 161 | textContent: 'other input', 162 | className: ['selected'], 163 | } as Prototype, 164 | context 165 | ); 166 | expect(receivedDOM.tagName).toBe('INPUT'); 167 | expect(receivedDOM.type).toBe('radio'); 168 | expect(receivedDOM).toMatchSnapshot(); 169 | 170 | render( 171 | { 172 | tag: 'input', 173 | setAttribute: { 174 | type: 'checkbox', 175 | }, 176 | textContent: 'other input', 177 | className: ['selected'], 178 | } as Prototype, 179 | context 180 | ); 181 | expect(receivedDOM.tagName).toBe('INPUT'); 182 | expect(receivedDOM.type).toBe('checkbox'); 183 | expect(receivedDOM).toMatchSnapshot(); 184 | 185 | render( 186 | { 187 | tag: 'input', 188 | setAttribute: { 189 | data: 'here', 190 | }, 191 | textContent: 'other input', 192 | className: ['selected'], 193 | } as Prototype, 194 | context 195 | ); 196 | expect(receivedDOM.tagName).toBe('INPUT'); 197 | expect(receivedDOM.type).toBe(defaultType); 198 | expect(receivedDOM).toMatchSnapshot(); 199 | 200 | render( 201 | { 202 | tag: 'input', 203 | textContent: 'other input', 204 | className: ['selected'], 205 | } as Prototype, 206 | context 207 | ); 208 | expect(receivedDOM.tagName).toBe('INPUT'); 209 | expect(receivedDOM.type).toBe(defaultType); 210 | expect(receivedDOM).toMatchSnapshot(); 211 | }); 212 | -------------------------------------------------------------------------------- /__tests__/perf.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import { buildStore, Prototype, render } from '../src'; 5 | 6 | test('multiple blocking store render', () => { 7 | interface StoreActionsType { 8 | increase: () => void; 9 | decrease: () => void; 10 | } 11 | interface StoreType { 12 | count: number; 13 | } 14 | const actions = (store: StoreType): StoreActionsType => ({ 15 | increase: () => store.count++, 16 | decrease: () => store.count--, 17 | }); 18 | 19 | const store: StoreType = { count: 1 }; 20 | const mockable = jest.fn(() => {}); 21 | const Counter = ( 22 | { count }: StoreType, 23 | { increase, decrease }: StoreActionsType, 24 | devMode: boolean 25 | ) => { 26 | mockable(); 27 | const start = Date.now(); 28 | while (Date.now() - start < 100) { 29 | continue; 30 | } 31 | return { 32 | tag: 'div', 33 | children: [ 34 | devMode && 35 | ({ 36 | tag: 'span', 37 | textContent: '' + count, 38 | className: [devMode && 'dev'], 39 | } as Prototype), 40 | { 41 | tag: 'button', 42 | textContent: 'increase', 43 | onclick: increase, 44 | } as Prototype, 45 | { 46 | tag: 'button', 47 | textContent: 'decrease', 48 | onclick: decrease, 49 | } as Prototype, 50 | ], 51 | }; 52 | }; 53 | const context = {}; 54 | const receivedDOM = render( 55 | buildStore(Counter, actions, store)(true), 56 | context 57 | ); 58 | expect(receivedDOM).toMatchSnapshot(); 59 | expect(receivedDOM.children[0].textContent).toBe('1'); 60 | 61 | return new Promise((accept, reject) => { 62 | const start = Date.now(); 63 | let i = 0; 64 | for (i = 0; i < 21; i++) { 65 | const tempI = i; 66 | setTimeout(() => { 67 | (receivedDOM.children[tempI % 2 ? 1 : 2] as HTMLButtonElement) 68 | .onclick(this) 69 | .then(() => { 70 | expect(mockable.mock.calls.length).toBe(2); 71 | expect(receivedDOM.children[0].textContent).toBe('0'); 72 | render(buildStore(Counter, actions, store)(), context); 73 | if (tempI === 20) { 74 | const duration = Date.now() - start; 75 | expect(duration).toBeLessThan(1000); 76 | accept(true); 77 | } 78 | }) 79 | .catch(() => {}); 80 | }); 81 | } 82 | }); 83 | }); 84 | 85 | test('multiple blocking store render with chunks', () => { 86 | const heavyComponent = () => { 87 | const start = Date.now(); 88 | while (Date.now() - start < 100) { 89 | continue; 90 | } 91 | return { tag: 'div', textContent: 'Hello' }; 92 | }; 93 | interface StoreActionsType { 94 | increase: () => void; 95 | decrease: () => void; 96 | } 97 | interface StoreType { 98 | count: number; 99 | } 100 | const actions = (store: StoreType): StoreActionsType => ({ 101 | increase: () => store.count++, 102 | decrease: () => store.count--, 103 | }); 104 | 105 | const store: StoreType = { count: 1 }; 106 | const mockable = jest.fn(() => {}); 107 | const Counter = ( 108 | { count }: StoreType, 109 | { increase, decrease }: StoreActionsType, 110 | devMode: boolean 111 | ) => { 112 | mockable(); 113 | const start = Date.now(); 114 | while (Date.now() - start < 100) { 115 | continue; 116 | } 117 | const children = []; 118 | children.push( 119 | devMode && 120 | ({ 121 | tag: 'span', 122 | textContent: '' + count, 123 | className: [devMode && 'dev'], 124 | } as Prototype), 125 | { 126 | tag: 'button', 127 | textContent: 'increase', 128 | onclick: increase, 129 | } as Prototype, 130 | { 131 | tag: 'button', 132 | textContent: 'decrease', 133 | onclick: decrease, 134 | } as Prototype 135 | ); 136 | 137 | let i = 0; 138 | for (i = 0; i < 20; i++) { 139 | children.push(heavyComponent); 140 | } 141 | return { 142 | tag: 'div', 143 | children, 144 | }; 145 | }; 146 | 147 | const context = {}; 148 | const receivedDOM = render( 149 | buildStore(Counter, actions, store, 100)(true), 150 | context 151 | ); 152 | expect(receivedDOM).toMatchSnapshot(); 153 | expect(receivedDOM.children[0].textContent).toBe('1'); 154 | 155 | return new Promise((accept, reject) => { 156 | const start = Date.now(); 157 | let i = 0; 158 | for (i = 0; i < 21; i++) { 159 | const tempI = i; 160 | setTimeout(() => { 161 | const startChunk = Date.now(); 162 | 163 | const element = receivedDOM.children[ 164 | tempI % 2 ? 1 : 2 165 | ] as HTMLButtonElement; 166 | element 167 | .onclick(this) 168 | .then(() => { 169 | const duration = Date.now() - startChunk; 170 | expect(duration).toBeLessThan(300); 171 | expect(mockable.mock.calls.length).toBe(2); 172 | expect(receivedDOM.children[0].textContent).toBe('0'); 173 | render(buildStore(Counter, actions, store)(), context); 174 | if (tempI === 20) { 175 | const duration = Date.now() - start; 176 | expect(duration).toBeLessThan(5000); 177 | accept(true); 178 | } 179 | }) 180 | .catch(() => {}); 181 | }, 2000); 182 | } 183 | }); 184 | }); 185 | 186 | test('render component with chunks', () => { 187 | const heavyComponent = () => { 188 | const start = Date.now(); 189 | while (Date.now() - start < 100) { 190 | continue; 191 | } 192 | return { tag: 'span', textContent: 'Hello' }; 193 | }; 194 | const Component = (): Prototype => { 195 | const children = []; 196 | let i = 0; 197 | for (i = 0; i < 20; i++) { 198 | children.push(heavyComponent); 199 | } 200 | return { 201 | tag: 'div', 202 | children, 203 | }; 204 | }; 205 | 206 | const start = Date.now(); 207 | const receivedDOM = render(Component(), {}, 300); 208 | 209 | const duration = Date.now() - start; 210 | expect(duration).toBeLessThan(600); 211 | 212 | return new Promise(accept => { 213 | setTimeout(() => { 214 | expect(receivedDOM.children.length).toBe(20); 215 | accept(true); 216 | }, 2000); 217 | }); 218 | }); 219 | -------------------------------------------------------------------------------- /__tests__/children.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import { Prototype, pure, render } from '../src'; 5 | 6 | test('remove one child', () => { 7 | const child1Text = 'child 1'; 8 | const child2Text = 'child 2'; 9 | const child3Text = 'child 3'; 10 | const context = {}; 11 | const receivedDOM = render( 12 | { 13 | tag: 'div', 14 | className: ['form-item'], 15 | children: [ 16 | { 17 | tag: 'input', 18 | type: 'label', 19 | textContent: child1Text, 20 | className: ['form'], 21 | } as Prototype, 22 | { 23 | tag: 'input', 24 | textContent: child2Text, 25 | className: ['selected'], 26 | } as Prototype, 27 | { 28 | tag: 'span', 29 | textContent: child3Text, 30 | className: ['warning'], 31 | } as Prototype, 32 | ], 33 | }, 34 | context 35 | ); 36 | expect((receivedDOM.children[0] as HTMLInputElement).textContent).toBe( 37 | child1Text 38 | ); 39 | expect((receivedDOM.children[1] as HTMLInputElement).textContent).toBe( 40 | child2Text 41 | ); 42 | expect((receivedDOM.children[2] as HTMLSpanElement).textContent).toBe( 43 | child3Text 44 | ); 45 | expect(receivedDOM).toMatchSnapshot(); 46 | render( 47 | { 48 | tag: 'div', 49 | className: ['form-item'], 50 | children: [ 51 | { 52 | tag: 'input', 53 | type: 'label', 54 | textContent: child1Text, 55 | className: ['form'], 56 | } as Prototype, 57 | { 58 | tag: 'span', 59 | textContent: child3Text, 60 | className: ['warning'], 61 | } as Prototype, 62 | ], 63 | }, 64 | context 65 | ); 66 | expect(receivedDOM.children.length).toBe(2); 67 | expect((receivedDOM.children[1] as HTMLInputElement).textContent).toBe( 68 | child3Text 69 | ); 70 | expect(receivedDOM).toMatchSnapshot(); 71 | }); 72 | 73 | test('move one keyed child - keep pure', () => { 74 | const child1Text = 'child 1'; 75 | const child2Text = 'child 2'; 76 | const child3Text = 'child 3'; 77 | const context = {}; 78 | const mockable = jest.fn((x: string) => x); 79 | const Warning = pure((text: string) => { 80 | mockable(text); 81 | return { 82 | tag: 'span', 83 | textContent: text, 84 | className: ['warning'], 85 | } as Prototype; 86 | }); 87 | const receivedDOM = render( 88 | { 89 | tag: 'div', 90 | className: ['form-item'], 91 | children: [ 92 | [ 93 | { 94 | tag: 'input', 95 | type: 'label', 96 | textContent: child1Text, 97 | className: ['form'], 98 | } as Prototype, 99 | 'child1', 100 | ], 101 | [ 102 | { 103 | tag: 'input', 104 | textContent: child2Text, 105 | className: ['selected'], 106 | } as Prototype, 107 | 'child2', 108 | ], 109 | [Warning(child3Text), 'child3'], 110 | ], 111 | }, 112 | context 113 | ); 114 | expect((receivedDOM.children[0] as HTMLInputElement).textContent).toBe( 115 | child1Text 116 | ); 117 | expect((receivedDOM.children[1] as HTMLInputElement).textContent).toBe( 118 | child2Text 119 | ); 120 | expect((receivedDOM.children[2] as HTMLSpanElement).textContent).toBe( 121 | child3Text 122 | ); 123 | expect(receivedDOM).toMatchSnapshot(); 124 | render( 125 | { 126 | tag: 'div', 127 | className: ['form-item'], 128 | children: [ 129 | [ 130 | { 131 | tag: 'input', 132 | type: 'label', 133 | textContent: child1Text, 134 | className: ['form'], 135 | } as Prototype, 136 | 'child1', 137 | ], 138 | [Warning(child3Text), 'child3'], 139 | [ 140 | { 141 | tag: 'input', 142 | textContent: child2Text, 143 | className: ['selected'], 144 | } as Prototype, 145 | 'child2', 146 | ], 147 | ], 148 | }, 149 | context 150 | ); 151 | expect(receivedDOM).toMatchSnapshot(); 152 | expect((receivedDOM.children[1] as HTMLInputElement).textContent).toBe( 153 | child3Text 154 | ); 155 | expect(mockable.mock.calls.length).toBe(1); 156 | 157 | render( 158 | { 159 | tag: 'div', 160 | className: ['form-item'], 161 | children: [ 162 | [Warning(child3Text), 'child3'], 163 | [ 164 | { 165 | tag: 'input', 166 | type: 'label', 167 | textContent: child1Text, 168 | className: ['form'], 169 | } as Prototype, 170 | 'child1', 171 | ], 172 | ], 173 | }, 174 | context 175 | ); 176 | expect(receivedDOM).toMatchSnapshot(); 177 | expect((receivedDOM.children[1] as HTMLInputElement).textContent).toBe( 178 | child1Text 179 | ); 180 | expect(mockable.mock.calls.length).toBe(1); 181 | }); 182 | 183 | test('render with condition', () => { 184 | const context = {}; 185 | const Form = (withText: boolean): Prototype => { 186 | return { 187 | tag: 'div', 188 | className: ['form-item'], 189 | children: [ 190 | withText && 191 | ({ 192 | tag: 'p', 193 | textContent: 'some text', 194 | className: ['form'], 195 | } as Prototype), 196 | { 197 | tag: 'input', 198 | textContent: 'other input', 199 | className: ['selected'], 200 | } as Prototype, 201 | ], 202 | }; 203 | }; 204 | const receivedDOM = render(Form(true), context); 205 | expect(receivedDOM.tagName).toBe('DIV'); 206 | expect(receivedDOM.children.length).toBe(2); 207 | expect(receivedDOM.children[0].tagName).toBe('P'); 208 | expect(receivedDOM.children[1].tagName).toBe('INPUT'); 209 | expect(receivedDOM).toMatchSnapshot(); 210 | 211 | render(Form(false), context); 212 | expect(receivedDOM.tagName).toBe('DIV'); 213 | expect(receivedDOM.children.length).toBe(1); 214 | expect(receivedDOM.children[0].tagName).toBe('INPUT'); 215 | expect(receivedDOM).toMatchSnapshot(); 216 | 217 | render(Form(true), context); 218 | expect(receivedDOM.children.length).toBe(2); 219 | expect(receivedDOM.children[0].tagName).toBe('P'); 220 | expect(receivedDOM.children[1].tagName).toBe('INPUT'); 221 | expect(receivedDOM).toMatchSnapshot(); 222 | }); 223 | -------------------------------------------------------------------------------- /__tests__/update.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import { Prototype, pure, render } from '../src'; 5 | 6 | test('update textContent', () => { 7 | const expectedText1 = 'child text'; 8 | const expectedText2 = 'modified by diff'; 9 | const context = {}; 10 | const receivedDOM = render( 11 | { tag: 'p', textContent: expectedText1 } as Prototype, 12 | context 13 | ); 14 | expect((receivedDOM as HTMLParagraphElement).textContent).toBe(expectedText1); 15 | render( 16 | { tag: 'p', textContent: expectedText2 } as Prototype, 17 | context 18 | ); 19 | expect((receivedDOM as HTMLParagraphElement).textContent).toBe(expectedText2); 20 | expect(receivedDOM).toMatchSnapshot(); 21 | }); 22 | 23 | test('remove one className', () => { 24 | const expectedText1 = 'child text'; 25 | const expectedText2 = 'modified by diff'; 26 | const className1 = 'selected'; 27 | const context = {}; 28 | const receivedDOM = render( 29 | { 30 | tag: 'p', 31 | textContent: expectedText1, 32 | className: [className1, 'highlighted'], 33 | } as Prototype, 34 | context 35 | ); 36 | expect((receivedDOM as HTMLParagraphElement).textContent).toBe(expectedText1); 37 | expect((receivedDOM as HTMLParagraphElement).classList).toContain(className1); 38 | render( 39 | { 40 | tag: 'p', 41 | textContent: expectedText2, 42 | className: ['highlighted'], 43 | } as Prototype, 44 | context 45 | ); 46 | expect((receivedDOM as HTMLParagraphElement).textContent).toBe(expectedText2); 47 | expect((receivedDOM as HTMLParagraphElement).classList).not.toContain( 48 | className1 49 | ); 50 | expect(receivedDOM).toMatchSnapshot(); 51 | }); 52 | 53 | test('remove all classNames', () => { 54 | const expectedText1 = 'child text'; 55 | const expectedText2 = 'modified by diff'; 56 | const className1 = 'selected'; 57 | const context = {}; 58 | const receivedDOM = render( 59 | { 60 | tag: 'p', 61 | textContent: expectedText1, 62 | className: [className1, 'highlighted'], 63 | } as Prototype, 64 | context 65 | ); 66 | expect((receivedDOM as HTMLParagraphElement).textContent).toBe(expectedText1); 67 | expect((receivedDOM as HTMLParagraphElement).classList).toContain(className1); 68 | render( 69 | { tag: 'p', textContent: expectedText2 } as Prototype, 70 | context 71 | ); 72 | expect((receivedDOM as HTMLParagraphElement).textContent).toBe(expectedText2); 73 | expect((receivedDOM as HTMLParagraphElement).classList).not.toContain( 74 | className1 75 | ); 76 | expect(receivedDOM).toMatchSnapshot(); 77 | }); 78 | 79 | test('update style', () => { 80 | const expectedText1 = 'child text'; 81 | const expectedText2 = 'modified by diff'; 82 | const expectedColor1 = 'white'; 83 | const expectedColor2 = 'black'; 84 | const backgroundColor = 'black'; 85 | const context = {}; 86 | const receivedDOM = render( 87 | { 88 | tag: 'p', 89 | textContent: expectedText1, 90 | style: { 91 | backgroundColor, 92 | }, 93 | } as Prototype, 94 | context 95 | ); 96 | const defaultColor = receivedDOM.style.color; 97 | render( 98 | { 99 | tag: 'p', 100 | textContent: expectedText1, 101 | style: { 102 | color: expectedColor1, 103 | backgroundColor: 'blue', 104 | }, 105 | } as Prototype, 106 | context 107 | ); 108 | expect((receivedDOM as HTMLParagraphElement).textContent).toBe(expectedText1); 109 | expect((receivedDOM as HTMLParagraphElement).style.color).toBe( 110 | expectedColor1 111 | ); 112 | render( 113 | { 114 | tag: 'p', 115 | textContent: expectedText2, 116 | className: ['highlighted'], 117 | style: { 118 | color: expectedColor2, 119 | backgroundColor: 'blue', 120 | }, 121 | } as Prototype, 122 | context 123 | ); 124 | expect((receivedDOM as HTMLParagraphElement).textContent).toBe(expectedText2); 125 | expect((receivedDOM as HTMLParagraphElement).style.color).toBe( 126 | expectedColor2 127 | ); 128 | expect(receivedDOM).toMatchSnapshot(); 129 | 130 | render( 131 | { 132 | tag: 'p', 133 | textContent: expectedText2, 134 | className: ['highlighted'], 135 | style: { 136 | color: expectedColor2, 137 | backgroundColor, 138 | }, 139 | } as Prototype, 140 | context 141 | ); 142 | expect((receivedDOM as HTMLParagraphElement).textContent).toBe(expectedText2); 143 | expect((receivedDOM as HTMLParagraphElement).style.color).toBe( 144 | expectedColor2 145 | ); 146 | expect((receivedDOM as HTMLParagraphElement).style.backgroundColor).toBe( 147 | backgroundColor 148 | ); 149 | expect(receivedDOM).toMatchSnapshot(); 150 | 151 | render( 152 | { 153 | tag: 'p', 154 | textContent: expectedText2, 155 | className: ['highlighted'], 156 | } as Prototype, 157 | context 158 | ); 159 | expect((receivedDOM as HTMLParagraphElement).textContent).toBe(expectedText2); 160 | expect((receivedDOM as HTMLParagraphElement).style.color).toBe(defaultColor); 161 | expect(receivedDOM).toMatchSnapshot(); 162 | }); 163 | 164 | test('update children content and type', () => { 165 | const child1Text = 'child 1'; 166 | const child2Text = 'child 2'; 167 | const expectedText1 = 'modified for new item'; 168 | const expectedText2 = 'modified by diff'; 169 | const context = {}; 170 | const receivedDOM = render( 171 | { 172 | tag: 'div', 173 | className: ['form-item'], 174 | children: [ 175 | { 176 | tag: 'input', 177 | type: 'label', 178 | textContent: child1Text, 179 | className: ['form'], 180 | } as Prototype, 181 | { 182 | tag: 'input', 183 | textContent: child2Text, 184 | className: ['selected'], 185 | } as Prototype, 186 | ], 187 | }, 188 | context 189 | ); 190 | expect(receivedDOM.classList).toContain('form-item'); 191 | expect(receivedDOM.children[0].tagName).toBe('INPUT'); 192 | expect(receivedDOM.children[0].getAttribute('type')).toBe('label'); 193 | expect((receivedDOM.children[0] as HTMLInputElement).textContent).toBe( 194 | child1Text 195 | ); 196 | expect(receivedDOM.children[1].tagName).toBe('INPUT'); 197 | expect((receivedDOM.children[1] as HTMLInputElement).textContent).toBe( 198 | child2Text 199 | ); 200 | expect(receivedDOM).toMatchSnapshot(); 201 | render( 202 | { 203 | tag: 'div', 204 | className: ['form-item'], 205 | children: [ 206 | { 207 | tag: 'p', 208 | textContent: expectedText1, 209 | className: ['form'], 210 | } as Prototype, 211 | { 212 | tag: 'input', 213 | textContent: expectedText2, 214 | className: ['selected'], 215 | } as Prototype, 216 | ], 217 | }, 218 | context 219 | ); 220 | expect(receivedDOM.classList).toContain('form-item'); 221 | expect(receivedDOM.children[0].tagName).toBe('P'); 222 | expect((receivedDOM.children[0] as HTMLInputElement).textContent).toBe( 223 | expectedText1 224 | ); 225 | expect(receivedDOM.children[0].classList).toContain('form'); 226 | expect(receivedDOM.children[1].tagName).toBe('INPUT'); 227 | expect((receivedDOM.children[1] as HTMLInputElement).textContent).toBe( 228 | expectedText2 229 | ); 230 | expect(receivedDOM).toMatchSnapshot(); 231 | }); 232 | 233 | test('update pure component', () => { 234 | const expectedText1 = 'Hello world'; 235 | const expectedText2 = 'Updated text'; 236 | const context = {}; 237 | const mockable = jest.fn((x: string) => x); 238 | const Warning = pure((text: string) => { 239 | mockable(text); 240 | return { 241 | tag: 'span', 242 | textContent: text, 243 | className: ['warning'], 244 | } as Prototype; 245 | }); 246 | const receivedDOM = render(Warning(expectedText1), context); 247 | render(Warning(expectedText1), context); 248 | render(Warning(expectedText1), context); 249 | expect((receivedDOM as HTMLSpanElement).textContent).toBe(expectedText1); 250 | expect(mockable.mock.calls.length).toBe(1); 251 | expect(receivedDOM).toMatchSnapshot(); 252 | 253 | render(Warning(expectedText2), context); 254 | render(Warning(expectedText2), context); 255 | expect((receivedDOM as HTMLSpanElement).textContent).toBe(expectedText2); 256 | expect(mockable.mock.calls.length).toBe(2); 257 | expect(receivedDOM).toMatchSnapshot(); 258 | 259 | render(Warning(expectedText1), context); 260 | render(Warning(expectedText1), context); 261 | expect((receivedDOM as HTMLSpanElement).textContent).toBe(expectedText1); 262 | expect(mockable.mock.calls.length).toBe(3); 263 | expect(receivedDOM).toMatchSnapshot(); 264 | }); 265 | -------------------------------------------------------------------------------- /__tests__/render.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import { 5 | asyncPure, 6 | buildStore, 7 | Prototype, 8 | pure, 9 | render, 10 | asyncRender, 11 | Context, 12 | inferType, 13 | } from '../src'; 14 | 15 | test('render innerText', () => { 16 | const expected = 'hello'; 17 | const receivedDOM = render({ tag: 'div', innerText: expected }, {}); 18 | expect(receivedDOM.innerText).toBe(expected); 19 | }); 20 | 21 | test('render one child', () => { 22 | const expected = 'child text'; 23 | const receivedDOM = render( 24 | { 25 | tag: 'div', 26 | children: [ 27 | { tag: 'p', textContent: expected } as Prototype, 28 | ], 29 | }, 30 | {} 31 | ); 32 | expect((receivedDOM.firstChild as HTMLParagraphElement).textContent).toBe( 33 | expected 34 | ); 35 | expect(receivedDOM).toMatchSnapshot(); 36 | }); 37 | 38 | test('render two children', () => { 39 | const child1Text = 'child 1'; 40 | const child2Text = 'child 2'; 41 | const receivedDOM = render( 42 | { 43 | tag: 'div', 44 | className: ['form-item'], 45 | children: [ 46 | { 47 | tag: 'input', 48 | type: 'label', 49 | textContent: child1Text, 50 | className: ['form'], 51 | } as Prototype, 52 | { 53 | tag: 'input', 54 | textContent: child2Text, 55 | className: ['selected'], 56 | } as Prototype, 57 | ], 58 | }, 59 | {} 60 | ); 61 | expect(receivedDOM.classList).toContain('form-item'); 62 | expect(receivedDOM.children[0].tagName).toBe('INPUT'); 63 | expect(receivedDOM.children[0].getAttribute('type')).toBe('label'); 64 | expect((receivedDOM.children[0] as HTMLInputElement).textContent).toBe( 65 | child1Text 66 | ); 67 | expect(receivedDOM.children[1].tagName).toBe('INPUT'); 68 | expect((receivedDOM.children[1] as HTMLInputElement).textContent).toBe( 69 | child2Text 70 | ); 71 | expect(receivedDOM).toMatchSnapshot(); 72 | }); 73 | 74 | test('render component', () => { 75 | const UserImage = ( 76 | name: string, 77 | imageUrl: string 78 | ): Prototype => ({ 79 | tag: 'div', 80 | children: [ 81 | { tag: 'span', textContent: name }, 82 | { tag: 'img', src: imageUrl } as Prototype, 83 | ], 84 | }); 85 | const userInfo = { 86 | name: 'Mike', 87 | imageUrl: 'https://i.picsum.photos/id/320/200/300.jpg', 88 | }; 89 | const receivedDOM = render(UserImage(userInfo.name, userInfo.imageUrl), {}); 90 | expect(receivedDOM.children[0].tagName).toBe('SPAN'); 91 | expect(receivedDOM.children[0].textContent).toBe(userInfo.name); 92 | expect(receivedDOM.children[1].tagName).toBe('IMG'); 93 | expect(receivedDOM.children[1].getAttribute('src')).toBe(userInfo.imageUrl); 94 | expect(receivedDOM).toMatchSnapshot(); 95 | }); 96 | 97 | test('render store', () => { 98 | interface StoreActionsType { 99 | increase: () => void; 100 | decrease: () => void; 101 | } 102 | interface StoreType { 103 | count: number; 104 | } 105 | const actions = (store: StoreType): StoreActionsType => ({ 106 | increase: () => { 107 | store.count++; 108 | }, 109 | decrease: () => store.count--, 110 | }); 111 | 112 | const store: StoreType = { count: 1 }; 113 | const Counter = ( 114 | { count }: StoreType, 115 | { increase, decrease }: StoreActionsType, 116 | devMode: boolean 117 | ) => ({ 118 | tag: 'div', 119 | children: [ 120 | devMode && 121 | ({ 122 | tag: 'span', 123 | textContent: '' + count, 124 | className: [devMode && 'dev'], 125 | } as Prototype), 126 | { 127 | tag: 'button', 128 | textContent: 'increase', 129 | onclick: increase, 130 | } as Prototype, 131 | { 132 | tag: 'button', 133 | textContent: 'decrease', 134 | onclick: decrease, 135 | } as Prototype, 136 | ], 137 | }); 138 | const context = {}; 139 | const receivedDOM = render( 140 | buildStore(Counter, actions, store)(true), 141 | context 142 | ); 143 | expect(receivedDOM).toMatchSnapshot(); 144 | expect(receivedDOM.children[0].textContent).toBe('1'); 145 | return (receivedDOM.children[1] as HTMLButtonElement) 146 | .onclick(this) 147 | .then(() => { 148 | expect(receivedDOM.children[0].textContent).toBe('2'); 149 | expect(receivedDOM).toMatchSnapshot(); 150 | render(buildStore(Counter, actions, store)(), context); 151 | expect(receivedDOM.children[0].textContent).not.toBe('1'); 152 | }); 153 | }); 154 | 155 | test('render pure component', () => { 156 | const expectedText = '2, was: 1'; 157 | const mockable = jest.fn((x: number) => x); 158 | const CounterInternal = (count: number, oldCount: number) => { 159 | mockable(count); 160 | return { 161 | tag: 'div', 162 | children: [ 163 | { 164 | tag: 'span', 165 | textContent: 166 | '' + count + (oldCount !== undefined ? ', was: ' + oldCount : ''), 167 | className: ['dev'], 168 | } as Prototype, 169 | { 170 | tag: 'button', 171 | textContent: 'increase', 172 | } as Prototype, 173 | { 174 | tag: 'button', 175 | textContent: 'decrease', 176 | } as Prototype, 177 | ], 178 | }; 179 | }; 180 | const Counter = pure(CounterInternal); 181 | const context = {}; 182 | const receivedDOM = render(Counter(1), context); 183 | render(Counter(1), context); 184 | render(Counter(1), context); 185 | expect(receivedDOM).toMatchSnapshot(); 186 | expect(receivedDOM.children[0].textContent).toBe('1'); 187 | const receivedDOM2 = render(Counter(1), context); 188 | expect(mockable.mock.calls.length).toBe(1); 189 | expect(receivedDOM2.children[0].textContent).toBe('1'); 190 | const receivedDOM3 = render(Counter(2), context); 191 | render(Counter(2), context); 192 | render(Counter(2), context); 193 | expect(mockable.mock.calls.length).toBe(2); 194 | expect(receivedDOM3.children[0].textContent).toBe(expectedText); 195 | expect(receivedDOM).toMatchSnapshot(); 196 | }); 197 | 198 | test('render async pure component', async () => { 199 | const expectedText = '2, was: 1'; 200 | const mockable = jest.fn((x: number) => x); 201 | const CounterInternal = (count: number, oldCount: number) => { 202 | return new Promise(resolve => { 203 | mockable(count); 204 | setTimeout( 205 | () => 206 | resolve({ 207 | tag: 'div', 208 | children: [ 209 | { 210 | tag: 'span', 211 | textContent: 212 | '' + 213 | count + 214 | (oldCount !== undefined ? ', was: ' + oldCount : ''), 215 | className: ['dev'], 216 | } as Prototype, 217 | { 218 | tag: 'button', 219 | textContent: 'increase', 220 | } as Prototype, 221 | { 222 | tag: 'button', 223 | textContent: 'decrease', 224 | } as Prototype, 225 | ], 226 | }), 227 | 100 228 | ); 229 | }); 230 | }; 231 | const Counter = asyncPure(CounterInternal); 232 | const context = {}; 233 | const receivedDOM = await asyncRender(Counter(1), context); 234 | render(Counter(1), context); 235 | render(Counter(1), context); 236 | expect(receivedDOM).toMatchSnapshot(); 237 | expect(receivedDOM.children[0].textContent).toBe('1'); 238 | const receivedDOM2 = render(Counter(1), context); 239 | expect(mockable.mock.calls.length).toBe(1); 240 | expect(receivedDOM2.children[0].textContent).toBe('1'); 241 | const receivedDOM3 = await asyncRender(Counter(2), context); 242 | render(Counter(2), context); 243 | render(Counter(2), context); 244 | expect(mockable.mock.calls.length).toBe(2); 245 | expect(receivedDOM3.children[0].textContent).toBe(expectedText); 246 | expect(receivedDOM).toMatchSnapshot(); 247 | }); 248 | 249 | test('render contextual component', () => { 250 | const newName = 'Max'; 251 | const UserImage = (name: string) => ( 252 | context: Context 253 | ): Prototype => ({ 254 | tag: 'div', 255 | children: [ 256 | { tag: 'span', textContent: name }, 257 | inferType({ 258 | tag: 'button', 259 | onclick: () => render(UserImage(newName), context), 260 | }), 261 | ], 262 | }); 263 | const userInfo = { 264 | name: 'Mike', 265 | }; 266 | const receivedDOM = render(UserImage(userInfo.name), {}); 267 | expect(receivedDOM.children[0].textContent).toBe(userInfo.name); 268 | expect(receivedDOM).toMatchSnapshot(); 269 | (receivedDOM.children[1] as HTMLButtonElement).onclick(this); 270 | expect(receivedDOM.children[0].textContent).toBe(newName); 271 | expect(receivedDOM).toMatchSnapshot(); 272 | }); 273 | -------------------------------------------------------------------------------- /website/src/dynamic/blog.article.ts: -------------------------------------------------------------------------------- 1 | import { buildStore as buildIt, Prototype, render } from 'prodom' 2 | import './blog.article.css' 3 | import Editor from './editor' 4 | import { darkIcon, devIcon } from './icons' 5 | import icon from '../icon-trans.png' 6 | export interface BlogArticleProps { 7 | title: string 8 | subtitle: string 9 | date: string 10 | link: string 11 | body: string 12 | devMode: boolean 13 | dark: boolean 14 | } 15 | 16 | const article: BlogArticleProps = { 17 | title: 'An easy to use web framework', 18 | subtitle: 'Prodom, the next open web framework', 19 | date: 20 | 'July 7th - 2021 - Changelog', 21 | link: 'https://github.com/m3ftah/prodom', 22 | body: ` 23 | Prodom is a 2kB library that helps you build web apps. 24 | By design, it is a declarative framework, component based and easy to use. 25 | Moreover, it can be integrated into already existing projects with no lock-in, as it only works on a DOM object, 26 | and exports a dom object. 27 |
28 | Prodom allows you to to compose and manage complex dom elements using pure idiomatic javascript. 29 | You do not need to learn anything new aside from the prototype concept and the store structure. 30 |
31 | Prodom follows a Flux Architecture concept by providing you with a built-in store/actions design. 32 |
33 | The main motivation behind providing such a framework is the complexity and the overhead given by popular web frameworks. 34 | Not to mention the lock-in, the library size and the complex buggy APIs. 35 |
36 | If you are already familiar with some standard concepts like: Flux Architecture, pure components and HTML5. 37 | Then you are ready to use Prodom. 38 |

Some behind the scene considerations

39 | 40 | Reconciliation and virtual DOM: these are used to automatically apply diffing whenever a new render is called. 41 | Thus, minimizing the number of DOM operations. 42 |
43 | Concurrent Mode: you can specify a timeout for which, the rendering will not block the UI for more than that timeout. 44 | This feature was only recently available in React.js (just after I gave up on it). 45 |
46 | Differed rendering: you can provide a promise on a component that will be rendered after a data is fetched. Meanwhile, 47 | you can provide another component as a placeholder. 48 |
49 | Automatic batching: when multiple store actions are called at the same time, the store state will be updated accordingly, 50 | but only one rendering function will be called at the end. 51 | While this feature is not yet available in React.js; it is provided by Prodom out of the box. 52 |
53 | Carried Context: this means you can render from anywhere. 54 | This may come handy if you want to extend Prodom. 55 |
56 | Finally, while Prodom has been heavily inspired by React.js, this is not a swiss army knife solution as React.js. 57 | The main reason behind building such framework is efficiency. 58 | With small size, few concepts to get around; you can code web apps faster with out of the box performance. 59 | `, 60 | devMode: false, 61 | dark: 62 | window.matchMedia && 63 | window.matchMedia('(prefers-color-scheme: dark)').matches, 64 | } 65 | 66 | const createBlogArticle = ( 67 | { title, subtitle, date, link, body, devMode = true, dark }: BlogArticleProps, 68 | { setDark, setDevMode }: BlogActions, 69 | ): Prototype => { 70 | const darkModeDOM: Prototype = { 71 | tag: 'div', 72 | className: ['control'], 73 | style: { backgroundColor: dark ? '#bbb' : '#666' }, 74 | onclick: () => setDark(!dark), 75 | contentEditable: '' + devMode, 76 | children: [darkIcon(dark, '#666', '#bbb')], 77 | } 78 | const devModeDOM: Prototype = { 79 | tag: 'div', 80 | className: ['control'], 81 | onclick: () => setDevMode(!devMode), 82 | contentEditable: '' + devMode, 83 | children: [devIcon(dark, '#666', '#bbb')], 84 | } 85 | const headerDOM: Prototype = { 86 | tag: 'div', 87 | className: ['header'], 88 | children: [devModeDOM, darkModeDOM], 89 | } 90 | 91 | const style1 = (devMode: boolean) => ({ 92 | backgroundColor: devMode && 'gray', 93 | }) 94 | 95 | const style2 = { 96 | fontSize: '3rem', 97 | } 98 | 99 | const titleDOM = { 100 | tag: 'h1', 101 | className: ['blog-article-title', devMode && 'dev', dark && 'dark'], 102 | style: { ...style1(devMode), ...(devMode && style2) }, 103 | innerText: title, 104 | contentEditable: devMode, 105 | } 106 | 107 | const subtitleDOM = { 108 | tag: 'p', 109 | className: ['blog-article-subtitle', devMode && 'dev', dark && 'dark'], 110 | innerText: subtitle, 111 | contentEditable: devMode, 112 | } 113 | 114 | const linkDOM = { 115 | tag: 'a', 116 | className: ['blog-article-link', devMode && 'dev', dark && 'dark'], 117 | innerText: link, 118 | href: link, 119 | contentEditable: devMode, 120 | } 121 | 122 | const dateDOM = { 123 | tag: 'p', 124 | className: ['blog-article-date', devMode && 'dev', dark && 'dark'], 125 | innerHTML: date, 126 | contentEditable: devMode, 127 | } 128 | 129 | const bodyDOM: Prototype = { 130 | tag: 'p', 131 | className: ['blog-article-body', devMode && 'dev', dark && 'dark'], 132 | innerHTML: body, 133 | contentEditable: '' + devMode, 134 | } 135 | 136 | const exampleTitle = { 137 | tag: 'h2', 138 | innerText: 'Some examples', 139 | style: { 140 | marginBottom: '50px', 141 | }, 142 | } 143 | 144 | const buildStore = buildIt 145 | const container = { 146 | tag: 'div', 147 | className: ['blog-article-container', devMode && 'dev', dark && 'dark'], 148 | children: [ 149 | titleDOM, 150 | subtitleDOM, 151 | linkDOM, 152 | dateDOM, 153 | { 154 | tag: 'img', 155 | src: icon, 156 | style: { 157 | display: 'inline-block', 158 | alignSelf: 'center', 159 | width: '96px', 160 | height: '96px', 161 | }, 162 | }, 163 | bodyDOM, 164 | exampleTitle, 165 | Editor( 166 | `{ 167 | tag: 'code', 168 | innerText: 'Hello world', 169 | }`, 170 | 'A simple prototype', 171 | 'https://codepen.io/m3ftah/pen/PopdwaG', 172 | devMode, 173 | dark, 174 | ), 175 | Editor( 176 | `{ 177 | tag: 'div', 178 | children: 179 | [ 180 | { 181 | tag: 'label', 182 | innerText: 'First child: ', 183 | }, 184 | { 185 | tag: 'input', 186 | value: 'Second child', 187 | }, 188 | { 189 | tag: 'button', 190 | innerText: 'Third child', 191 | }, 192 | ] 193 | }`, 194 | 'Composing prototypes', 195 | 'https://codepen.io/m3ftah/pen/ZEeexea', 196 | devMode, 197 | dark, 198 | ), 199 | Editor( 200 | `{ 201 | tag: 'code', 202 | innerText: 'Hello world', 203 | className: ['bold', dark && 'dark'] 204 | }`, 205 | 'Dynamic CSS Classes', 206 | 'https://codepen.io/m3ftah/pen/YzZOPez', 207 | devMode, 208 | dark, 209 | ), 210 | Editor( 211 | `{ 212 | tag: 'button', 213 | innerText: 'Click me!', 214 | onclick: ()=> setTitle('Title has been modified') 215 | }`, 216 | 'Events', 217 | 'https://codepen.io/m3ftah/pen/RwpBXzG', 218 | devMode, 219 | dark, 220 | ), 221 | Editor( 222 | `{ 223 | tag: 'div', 224 | innerText: 'I am styled', 225 | style: {display: 'flex' , alignSelf: ' center', padding: '16px', borderRadius: '8px', backgroundColor: '#29f'} 226 | }`, 227 | 'Styling', 228 | 'https://codepen.io/m3ftah/pen/jOBvEmw', 229 | devMode, 230 | dark, 231 | ), 232 | Editor( 233 | `() => { 234 | const prototype = ({ name }, { setName }) => ({ 235 | tag: 'div', 236 | children: [ 237 | { 238 | tag: 'input', 239 | value: name, 240 | oninput: (e) => setName(e.target.value) 241 | }, 242 | { 243 | tag: 'div', 244 | innerText: 'Name: ' + name, 245 | onclick: () => setName('User 1') 246 | } 247 | ] 248 | }) 249 | const actions = (state) => ({ 250 | setName: (name) => state.name = name 251 | }) 252 | return buildStore(prototype, actions, { name: 'NAME' })(resolvedDemo) 253 | }`, 254 | 'Store', 255 | 'https://codepen.io/m3ftah/pen/NWpLKdz', 256 | devMode, 257 | dark, 258 | ), 259 | ], 260 | contentEditable: '' + devMode, 261 | } 262 | const padding = { 263 | tag: 'div', 264 | className: ['padding', dark && 'dark'], 265 | contentEditable: '' + devMode, 266 | } 267 | return { 268 | init: () => { 269 | window 270 | .matchMedia('(prefers-color-scheme: dark)') 271 | .addEventListener('change', (e) => { 272 | const mode = e.matches 273 | setDark(mode) 274 | }) 275 | return document.createElement('body') 276 | }, 277 | className: ['blog-article-page', devMode && 'dev', dark && 'dark'], 278 | children: [headerDOM, container, padding], 279 | contentEditable: '' + devMode, 280 | } 281 | } 282 | type BlogActions = { 283 | setDark: (dark: boolean) => void 284 | setDevMode: (devMode: boolean) => void 285 | } 286 | const actions = (state: BlogArticleProps): BlogActions => ({ 287 | setDark: (dark: boolean) => { 288 | state.dark = dark 289 | }, 290 | setDevMode: (devMode: boolean) => { 291 | state.devMode = devMode 292 | }, 293 | }) 294 | export default render(buildIt(createBlogArticle, actions, article)(), {}) 295 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Context to be used to render the dom Node 3 | */ 4 | export interface Context { 5 | init?: string; 6 | dom?: T; 7 | children?: Context[]; 8 | oldProps?: Record; 9 | store?: any; 10 | actions?: any; 11 | oldArgs?: any[]; 12 | oldKeys?: any[]; 13 | virtual?: boolean; 14 | ticket?: number; 15 | } 16 | export const inferType = ( 17 | b: Prototype 18 | ): Prototype => { 19 | return b; 20 | }; 21 | export type Prototype = { 22 | tag?: string; 23 | virtual?: boolean; 24 | init?: () => T; 25 | children?: Prototype[] | [Prototype, string][]; 26 | component?: (...args: any[]) => Prototype; 27 | asyncComponent?: (...args: any[]) => Promise>; 28 | className?: string[]; 29 | style?: { [P in keyof CSSStyleDeclaration]?: CSSStyleDeclaration[P] }; 30 | freeze?: boolean; 31 | setAttribute?: Record; 32 | placeHolder?: Prototype; 33 | args?: any[]; 34 | resolve?: any; 35 | } & { 36 | [P in Exclude< 37 | keyof T, 38 | 'className' | 'children' | 'style' | 'setAttribute' 39 | >]?: T[P]; 40 | }; 41 | /** 42 | * 43 | * The render function 44 | * @param toRender A prototype, or a method taking context and returning the prototype 45 | * @param context the context in which to render this prototype 46 | * @param timeout render per time chunks of timeout (ms), in order to not block the thread. 47 | * @returns The actual dom node 48 | */ 49 | export const render = ( 50 | toRender: Prototype | ((context: Context) => Prototype), 51 | context: Context, 52 | timeout?: number, 53 | startedTime?: number 54 | ): U => { 55 | let startedTimeCalculated = startedTime; 56 | if (timeout !== undefined && startedTimeCalculated === undefined) { 57 | startedTimeCalculated = Date.now(); 58 | } 59 | if (typeof toRender === 'function') { 60 | return render(toRender(context), context, timeout, startedTimeCalculated); 61 | } 62 | if ( 63 | toRender.component !== undefined || 64 | toRender.asyncComponent !== undefined 65 | ) { 66 | //Pure component 67 | if (arraysEqual(context.oldArgs, toRender.args)) { 68 | return context.dom; 69 | } 70 | const oldArgs = context.oldArgs || []; 71 | context.oldArgs = [...toRender.args]; 72 | if (toRender.component !== undefined) { 73 | return render( 74 | toRender.component?.(...toRender.args, ...oldArgs), 75 | context, 76 | timeout, 77 | startedTimeCalculated 78 | ); 79 | } 80 | if (toRender.asyncComponent !== undefined) { 81 | //Async component 82 | toRender 83 | .asyncComponent(...toRender.args, ...oldArgs) 84 | .then(prototype => 85 | render(prototype, context, timeout, startedTimeCalculated) 86 | ) 87 | .then(toRender.resolve); 88 | if (context.dom === undefined) { 89 | if (toRender.placeHolder !== undefined) { 90 | return render( 91 | toRender.placeHolder, 92 | context, 93 | timeout, 94 | startedTimeCalculated 95 | ) as any; 96 | } 97 | return render( 98 | { tag: 'input', type: 'hidden' } as any, 99 | context, 100 | timeout, 101 | startedTimeCalculated 102 | ); 103 | } else { 104 | return context.dom; 105 | } 106 | } 107 | } 108 | if (toRender.freeze === true) { 109 | return; 110 | } 111 | const { tag, children, virtual, ...props } = toRender; 112 | if (context === undefined || context === null) { 113 | console.error('A context has not been provided'); 114 | return; 115 | } 116 | 117 | context.oldProps = context.oldProps || {}; 118 | context.children = context.children || []; 119 | 120 | const childrenComponents = 121 | (children as any[])?.filter((child: any) => child) || []; 122 | 123 | const keys: any[] = []; 124 | const simplifiedChildren = []; 125 | childrenComponents.forEach((child: Prototype, index: number) => { 126 | if (Array.isArray(child)) { 127 | keys.push(child[1]); 128 | //Keyed arrays 129 | if (context.children[child[1]] === undefined) { 130 | context.children[child[1]] = {} as Context; 131 | } 132 | simplifiedChildren.push({ 133 | child: child[0], 134 | context: context.children[child[1]], 135 | }); 136 | } else { 137 | keys.push(index); 138 | if (context.children[index] === undefined) { 139 | context.children[index] = {} as Context; 140 | } 141 | simplifiedChildren.push({ child, context: context.children[index] }); 142 | } 143 | }); 144 | 145 | let i = 0; 146 | const childrenDom = []; 147 | let tempStartedTime = startedTimeCalculated; 148 | let dettached = false; 149 | const count = () => { 150 | while ( 151 | i < simplifiedChildren.length && 152 | (tempStartedTime === undefined || Date.now() - tempStartedTime < timeout) 153 | ) { 154 | const simplified = simplifiedChildren[i]; 155 | childrenDom[i] = render( 156 | simplified.child, 157 | simplified.context, 158 | timeout, 159 | startedTimeCalculated 160 | ); 161 | if (dettached && context.dom !== undefined) { 162 | context.dom.append(childrenDom[i]); 163 | } 164 | i++; 165 | } 166 | if (i < simplifiedChildren.length) { 167 | dettached = true; 168 | tempStartedTime = Date.now(); 169 | setTimeout(count); 170 | } 171 | }; 172 | count(); 173 | 174 | context.oldKeys = context.oldKeys || keys; 175 | 176 | let creatorStr; 177 | if (!Object.prototype.hasOwnProperty.call(toRender, 'init')) { 178 | toRender.init = () => document.createElement(tag) as any; 179 | creatorStr = '' + tag; 180 | } else { 181 | creatorStr = '' + toRender.init; 182 | } 183 | const init = toRender.init; 184 | 185 | if (context.dom === undefined || context.init !== creatorStr) { 186 | // rerender the init 187 | context.init = creatorStr; 188 | if (context.dom) { 189 | const newDOM = init(); 190 | context.dom.replaceWith(newDOM); 191 | context.dom = newDOM; 192 | context.oldProps = {}; 193 | } else { 194 | context.dom = init(); 195 | } 196 | if (childrenDom.length > 0) { 197 | context.dom.append( 198 | ...childrenDom.filter(domChild => domChild !== undefined) 199 | ); 200 | } 201 | } else { 202 | //Add or remove children 203 | const newKeys = childrenComponents.map((child, index) => 204 | Array.isArray(child) ? child[1] : index 205 | ); 206 | if (!arraysEqual(newKeys, context.oldKeys)) { 207 | context.oldKeys = context.oldKeys.filter(oldKey => { 208 | //remove items 209 | const newIndex = 210 | newKeys.indexOf(oldKey) >= 0 211 | ? newKeys.indexOf(oldKey) 212 | : newKeys.indexOf(parseInt(oldKey)); 213 | if (newIndex < 0) { 214 | context.children[oldKey]?.dom?.remove?.(); 215 | context.children[oldKey] = undefined as Context; 216 | return false; 217 | } 218 | return true; 219 | }); 220 | 221 | newKeys.forEach((newKey, newIndex) => { 222 | //Add items 223 | const oldIndex = 224 | context.oldKeys.indexOf(newKey) >= 0 225 | ? context.oldKeys.indexOf(newKey) 226 | : context.oldKeys.indexOf(parseInt(newKey)); 227 | 228 | if (oldIndex < 0) { 229 | if (!context.children[newKey]?.virtual) { 230 | insertChildAtIndex( 231 | context?.dom, 232 | context.children[newKey]?.dom, 233 | newIndex 234 | ); 235 | } 236 | context.oldKeys.splice(newIndex, 0, newKey); 237 | } 238 | }); 239 | 240 | let countPositives = 0; 241 | const diffItem = newKeys.reduce((arr, current, newIndex) => { 242 | const oldIndex = 243 | context.oldKeys.indexOf(current) >= 0 244 | ? context.oldKeys.indexOf(current) 245 | : context.oldKeys.indexOf(parseInt(current)); 246 | const diff = newIndex - oldIndex || 0; 247 | if (diff > 0) { 248 | countPositives++; 249 | } 250 | if (diff !== 0) { 251 | arr.push({ diff, current, newIndex, oldIndex }); 252 | } 253 | return arr; 254 | }, []) as any[]; 255 | 256 | const countNegative = diffItem.length - countPositives; 257 | if (countPositives > countNegative) { 258 | diffItem.sort((a, b) => a.newIndex - b.newIndex); 259 | } else { 260 | diffItem.sort((a, b) => b.newIndex - a.newIndex); 261 | } 262 | 263 | diffItem.forEach(diffItem => { 264 | if (context.oldKeys[diffItem.newIndex] !== diffItem.current) { 265 | context.oldKeys.splice(context.oldKeys.indexOf(diffItem.current), 1); 266 | if (!context.children[diffItem.current]?.virtual) { 267 | insertChildAtIndex( 268 | context?.dom, 269 | context.children[diffItem.current]?.dom, 270 | diffItem.newIndex 271 | ); 272 | } 273 | context.oldKeys.splice(diffItem.newIndex, 0, diffItem.current); 274 | } 275 | }); 276 | } 277 | } 278 | 279 | Object.keys(context.oldProps).forEach(oldKey => { 280 | //Resetting unused properties to default 281 | if (!Object.prototype.hasOwnProperty.call(props, oldKey)) { 282 | (context.dom as any)[oldKey] = ''; 283 | delete context.oldProps[oldKey]; 284 | } else { 285 | const oldValue = context.oldProps[oldKey]; 286 | if (typeof oldValue === 'object' && oldValue !== null) { 287 | Object.keys(oldValue).forEach(oldStyleKey => { 288 | if ( 289 | !Object.prototype.hasOwnProperty.call( 290 | (props as any)[oldKey], 291 | oldStyleKey 292 | ) || 293 | (props as any)[oldKey][oldStyleKey] === false 294 | ) { 295 | if (oldKey === 'setAttribute') { 296 | context.dom.setAttribute(oldStyleKey, ''); 297 | } else { 298 | (context.dom as any)[oldKey][oldStyleKey] = ''; 299 | } 300 | delete context.oldProps[oldKey][oldStyleKey]; 301 | } 302 | }); 303 | } 304 | } 305 | }); 306 | 307 | Object.keys(props).forEach(key => { 308 | //diffing props 309 | const calculatedProp = (props as any)[key]; 310 | let nonArrayProps = calculatedProp; 311 | if (Array.isArray(nonArrayProps)) { 312 | nonArrayProps = nonArrayProps.filter(Boolean).join(' '); 313 | } 314 | if (typeof nonArrayProps === 'object' && nonArrayProps !== null) { 315 | if (context.oldProps[key] === undefined) { 316 | context.oldProps[key] = {}; 317 | } 318 | Object.keys(nonArrayProps).forEach(styleKey => { 319 | if (context.oldProps[key][styleKey] !== nonArrayProps[styleKey]) { 320 | context.oldProps[key][styleKey] = nonArrayProps[styleKey]; 321 | if (key === 'setAttribute') { 322 | context.dom.setAttribute(styleKey, nonArrayProps[styleKey]); 323 | } else { 324 | (context.dom as any)[key][styleKey] = nonArrayProps[styleKey]; 325 | } 326 | } 327 | }); 328 | } else { 329 | if (context.oldProps[key] !== nonArrayProps) { 330 | (context.dom as any)[key] = nonArrayProps; 331 | context.oldProps[key] = nonArrayProps; 332 | } 333 | } 334 | }); 335 | 336 | if (virtual) { 337 | context.virtual = true; 338 | return; 339 | } 340 | return context.dom; 341 | }; 342 | const insertChildAtIndex = ( 343 | parent: HTMLElement, 344 | child: HTMLElement, 345 | index: number 346 | ) => { 347 | if (child === undefined) { 348 | return; 349 | } 350 | if (index + 1 >= parent?.children.length) { 351 | parent?.appendChild(child); 352 | } else { 353 | parent?.insertBefore(child, parent?.children[index]); 354 | } 355 | }; 356 | /** 357 | * 358 | * @param component is a method that returns a prototype 359 | * @param actions an object that contains methods that receive the store 360 | * @param store an object that will be passed to the component and the actions 361 | * @param timeout render per time chunks of timeout in ms, in order to not block the thread. 362 | * @returns a component that can be passed to the render method 363 | */ 364 | export function buildStore( 365 | component: (...args: any[]) => Prototype, 366 | actions?: (store: any, mappedActions: any) => any, 367 | store?: any, 368 | timeout?: number 369 | ): any { 370 | //Called in export default 371 | //Put shared store here 372 | return (...args: any[]) => { 373 | //Called every time 374 | return (goodContext: Context) => ({ 375 | args: args, 376 | component: (...newArgs: any) => { 377 | //Called inside render 378 | const goodStore = 379 | goodContext.store !== undefined 380 | ? goodContext.store 381 | : store !== undefined 382 | ? store 383 | : {}; 384 | if (goodContext.store === undefined) { 385 | goodContext.store = goodStore; 386 | } 387 | goodContext.ticket = goodContext.ticket || 0; 388 | const simpleActions = {}; 389 | const goodActions = 390 | goodContext.actions !== undefined 391 | ? goodContext.actions 392 | : actions(goodStore, simpleActions); 393 | if (goodContext.actions === undefined) { 394 | goodContext.actions = goodActions; 395 | if (goodActions.init !== undefined) { 396 | goodActions.init(); 397 | } 398 | } 399 | 400 | const mappedActions = {}; 401 | Object.keys(goodActions).forEach(key => { 402 | (simpleActions as any)[key] = (...value: any[]) => 403 | Promise.resolve(goodActions[key](...value)); 404 | 405 | (mappedActions as any)[key] = (...value: any[]) => 406 | Promise.resolve(goodActions[key](...value)).then(() => { 407 | return new Promise((resolve, reject) => { 408 | goodContext.ticket = goodContext.ticket + 1; 409 | const localTicket = goodContext.ticket; 410 | setTimeout(() => { 411 | if (localTicket === goodContext.ticket) { 412 | resolve( 413 | render( 414 | component( 415 | goodContext.store, 416 | mappedActions, 417 | ...(newArgs || []) 418 | ) as any, 419 | goodContext as any, 420 | timeout 421 | ) 422 | ); 423 | } else { 424 | reject('Rendering was abondoned in favor of another'); 425 | } 426 | }); 427 | }); 428 | }); 429 | }); 430 | return component(goodContext.store, mappedActions, ...(newArgs || [])); 431 | }, 432 | }); 433 | }; 434 | } 435 | /** 436 | * This method can be used to only call the component method if the parameters are changed. 437 | * @param component A method returning the prototype 438 | * @returns a pure component that can be passed to the render method 439 | */ 440 | export const pure = (component: any) => (...args: any[]) => ({ 441 | component, 442 | args, 443 | }); 444 | /** 445 | * This method can be used to display async components 446 | * @param asyncComponent an async method that returns a promise on a prototype 447 | * @param placeHolder a prototype that will be displayed while waiting for the promise 448 | * @returns an async pure component that can be passed to the render method 449 | */ 450 | export const asyncPure = ( 451 | asyncComponent: any, 452 | placeHolder?: Prototype 453 | ) => (...args: any[]) => ({ 454 | asyncComponent, 455 | args, 456 | placeHolder, 457 | }); 458 | 459 | /** 460 | * 461 | * This helper function can be used to wait for an async pure component rendering, 462 | * which has been created using the asyncPure function 463 | * @param toRender A prototype, or a method taking context and returning the prototype 464 | * @param context the context in which to render this prototype 465 | * @param timeout render per time chunks of timeout (ms), in order to not block the thread. 466 | * @returns a promise on the actual rendered dom node 467 | */ 468 | export const asyncRender = ( 469 | toRender: Prototype | ((context: Context) => Prototype), 470 | context: Context, 471 | timeout?: number 472 | ): Promise => { 473 | return new Promise(resolve => 474 | render({ ...toRender, resolve } as Prototype, context, timeout) 475 | ); 476 | }; 477 | 478 | function arraysEqual(a: any[], b: any[]) { 479 | if (a === undefined) return false; 480 | if (b === undefined) return false; 481 | if (a === b) return true; 482 | if (a == null || b == null) return false; 483 | if (a.length !== b.length) return false; 484 | 485 | for (let i = 0; i < a.length; ++i) { 486 | if (a[i] !== b[i]) return false; 487 | } 488 | return true; 489 | } 490 | --------------------------------------------------------------------------------