├── 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  [](https://bundlephobia.com/result?p=prodom) [](https://www.npmjs.com/package/prodom) [](https://www.npmjs.com/package/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 |
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 |
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 |
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 |
52 |
53 | `;
54 |
55 | exports[`render contextual component 2`] = `
56 |
57 |
58 | Max
59 |
60 |
61 |
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 |
123 | `;
124 |
125 | exports[`render with condition 2`] = `
126 |
129 |
132 | other input
133 |
134 |
135 | `;
136 |
137 | exports[`render with condition 3`] = `
138 |
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 |
--------------------------------------------------------------------------------