├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ ├── build.yml │ ├── deploy-docs.yml │ └── test.yml ├── .gitignore ├── .husky ├── commit-msg ├── pre-commit └── pre-push ├── LICENSE.md ├── README.md ├── commitlint.config.cjs ├── config └── setup-tests.ts ├── docs ├── .vitepress │ ├── config.ts │ └── theme │ │ ├── HomePage.vue │ │ ├── custom.css │ │ └── index.ts └── index.md ├── example └── main.ts ├── index.html ├── lib ├── expression-parser.ts ├── hooks.ts ├── index.ts ├── renderer.spec.ts ├── renderer.ts └── types.ts ├── other └── slide.png ├── package.json ├── pnpm-lock.yaml ├── tsconfig.json ├── tsconfig.node.json ├── tsconfig.spec.json ├── vite.config.ts └── vitest.config.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | .idea/ 4 | .vscode/ 5 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | "plugin:@typescript-eslint/recommended", 4 | "plugin:prettier/recommended" 5 | ], 6 | parser: "@typescript-eslint/parser", 7 | plugins: ["prettier"], 8 | rules: { 9 | "@typescript-eslint/no-empty-function": "off", 10 | "prettier/prettier": [ 11 | "error", 12 | { 13 | endOfLine: "auto", 14 | }, 15 | ] 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: pnpm/action-setup@v2 16 | with: 17 | version: 8 18 | - uses: actions/setup-node@v3 19 | with: 20 | node-version: '18' 21 | cache: 'pnpm' 22 | 23 | - name: Install packages 24 | run: pnpm install --frozen-lockfile 25 | 26 | - name: Build 27 | run: pnpm build 28 | -------------------------------------------------------------------------------- /.github/workflows/deploy-docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: {} 9 | 10 | jobs: 11 | deploy-docs: 12 | permissions: 13 | contents: write # to write to gh-pages branch (peaceiris/actions-gh-pages) 14 | 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: pnpm/action-setup@v2 19 | with: 20 | version: 8 21 | - uses: actions/setup-node@v3 22 | with: 23 | node-version: '18' 24 | cache: 'pnpm' 25 | 26 | - name: Install packages 27 | run: pnpm install --frozen-lockfile 28 | 29 | - name: Build 30 | run: pnpm docs:build 31 | 32 | - name: Deploy docs 33 | uses: peaceiris/actions-gh-pages@v3 34 | with: 35 | github_token: ${{ secrets.GITHUB_TOKEN }} 36 | publish_dir: docs/.vitepress/dist 37 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: pnpm/action-setup@v2 16 | with: 17 | version: 8 18 | - uses: actions/setup-node@v3 19 | with: 20 | node-version: '18' 21 | cache: 'pnpm' 22 | 23 | - name: Install packages 24 | run: pnpm install --frozen-lockfile 25 | 26 | - name: Run tests 27 | run: pnpm test 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # Vitepress 27 | docs/.vitepress/cache/ 28 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit "${1}" 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | pnpm lint-staged 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | pnpm build 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Corbin Crutchley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

The Fun Framework

3 | 4 | 5 | playground slide 11 | 12 | 13 |

An experimental homegrown JS framework.

14 | 15 |
16 | 17 |
18 | 19 | [![Build Status](https://img.shields.io/github/actions/workflow/status/crutchcorn/the-fun-framework/build.yml?branch=main)](https://github.com/crutchcorn/the-fun-framework/actions/workflows/build.yml?query=branch%3Amain) 20 | [![Test Status](https://img.shields.io/github/actions/workflow/status/crutchcorn/the-fun-framework/test.yml?branch=main&label=tests)](https://github.com/crutchcorn/the-fun-framework/actions/workflows/test.yml?query=branch%3Amain) 21 | [![Pre-release](https://img.shields.io/npm/v/the-fun-framework.svg)](https://npm.im/the-fun-framework) 22 | [![gzip size](https://img.badgesize.io/https://unpkg.com/the-fun-framework@latest/dist/the-fun-framework.cjs?compression=gzip)](https://unpkg.com/browse/the-fun-framework@latest/dist/the-fun-framework.cjs) 23 | [![license](https://badgen.now.sh/badge/license/MIT)](./LICENSE.md) 24 | 25 |
26 | 27 | The goals of this project are: 28 | 29 | - HTML-first templating 30 | - No VDOM 31 | - Implicit re-renders (instead of calling an update function manually, "mutate") 32 | 33 | ## Installation 34 | 35 | ```shell 36 | npm install the-fun-framework 37 | ``` 38 | 39 | ## Usage 40 | 41 | ```html 42 | 43 | 44 | 45 |
46 |

{{message}}

47 |
48 | ``` 49 | 50 | ```typescript 51 | // index.ts 52 | import { createState, registerComponent, render } from "the-fun-framework"; 53 | 54 | function App() { 55 | return { 56 | message: "Hello, world", 57 | }; 58 | } 59 | 60 | // Register with the same name as `data-island-comp` 61 | App.selector = "App"; 62 | registerComponent(App); 63 | render(); 64 | ``` 65 | 66 | ### Conditional Display 67 | 68 | ```html 69 | 70 |
71 | 72 |

Count: {{count.value}}

73 |

{{count.value}} is even

74 |
75 | ``` 76 | 77 | ```typescript 78 | // index.ts 79 | import { createState, registerComponent, render } from "the-fun-framework"; 80 | 81 | function Counter() { 82 | let count = createState(0); 83 | 84 | function updateCount() { 85 | count.value++; 86 | } 87 | 88 | return { 89 | count, 90 | updateCount, 91 | }; 92 | } 93 | 94 | // Register with the same name as `data-island-comp` 95 | Counter.selector = "Counter"; 96 | registerComponent(Counter); 97 | render(); 98 | ``` 99 | 100 | ### Loop Display 101 | 102 | ```html 103 | 104 |
105 |

Names

106 | 109 | 110 |
111 | ``` 112 | 113 | ```typescript 114 | // index.ts 115 | function People() { 116 | const list = createState([ 117 | { 118 | name: "Corbin", 119 | key: "corbin", 120 | }, 121 | { 122 | name: "Ade", 123 | key: "ade", 124 | }, 125 | ]); 126 | 127 | let personCount = 0; 128 | function addPerson() { 129 | const newList = [...list.value]; 130 | ++personCount; 131 | newList.push({ 132 | name: `Person ${personCount}`, 133 | key: `person_${personCount}`, 134 | }); 135 | list.value = newList; 136 | } 137 | 138 | return { 139 | list, 140 | addPerson, 141 | }; 142 | } 143 | 144 | People.selector = "People"; 145 | registerComponent(People); 146 | render(); 147 | ``` 148 | -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ["@commitlint/config-conventional"] }; 2 | -------------------------------------------------------------------------------- /config/setup-tests.ts: -------------------------------------------------------------------------------- 1 | import { expect, afterEach } from "vitest"; 2 | import matchers, { 3 | TestingLibraryMatchers, 4 | } from "@testing-library/jest-dom/matchers"; 5 | 6 | import userEvent from "@testing-library/user-event"; 7 | 8 | expect.extend(matchers); 9 | 10 | globalThis.user = userEvent.setup(); 11 | 12 | declare global { 13 | // eslint-disable-next-line @typescript-eslint/no-namespace 14 | namespace Vi { 15 | interface JestAssertion 16 | extends jest.Matchers, 17 | TestingLibraryMatchers {} 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /docs/.vitepress/config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitepress"; 2 | 3 | const { description } = require("../../package.json"); 4 | 5 | export default defineConfig({ 6 | lang: "en-US", 7 | title: "The Fun Framework", 8 | description: description, 9 | base: "/the-fun-framework", 10 | lastUpdated: true, 11 | head: [ 12 | ["meta", { name: "theme-color", content: "#DBCAFF" }], 13 | ["meta", { property: "twitter:card", content: "summary_large_image" }], 14 | ["link", { rel: "icon", href: "/logo.svg", type: "image/svg+xml" }], 15 | ["link", { rel: "mask-icon", href: "/logo.svg", color: "#ffffff" }], 16 | ], 17 | themeConfig: { 18 | socialLinks: [ 19 | { icon: "github", link: "https://github.com/crutchcorn/the-fun-framework" }, 20 | ], 21 | editLink: { 22 | pattern: "https://github.com/crutchcorn/the-fun-framework/edit/main/docs/:path", 23 | } 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/HomePage.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 79 | 80 | 106 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/custom.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --vp-c-brand: #dd1f85; 3 | --vp-c-brand-dark: #a32987; 4 | --vp-c-brand-darker: #5d2f88; 5 | } 6 | 7 | html.dark { 8 | --vp-c-brand: #ef68ad; 9 | --vp-c-brand-dark: #ce46a3; 10 | --vp-c-brand-darker: #af45cc; 11 | --vp-button-brand-text: var(--vp-c-bg); 12 | --vp-button-brand-hover-text: var(--vp-c-bg-elv); 13 | --vp-button-brand-active-text: var(--vp-c-bg-elv-down); 14 | } 15 | 16 | :root { 17 | --vp-home-hero-name-color: transparent; 18 | --vp-home-hero-name-background: -webkit-linear-gradient( 19 | 120deg, 20 | #ef68ad 30%, 21 | #af45cc 22 | ); 23 | --vp-home-hero-image-background-image: linear-gradient( 24 | -45deg, 25 | #ef68ad 30%, 26 | #af45cc 27 | ); 28 | --vp-home-hero-image-filter: blur(30px); 29 | } 30 | 31 | .dark { 32 | --vp-home-hero-name-background: -webkit-linear-gradient( 33 | 120deg, 34 | #ef68ad 30%, 35 | #af45cc 36 | ); 37 | --vp-home-hero-image-background-image: linear-gradient( 38 | -45deg, 39 | #ef68ad 30%, 40 | #af45cc 41 | ); 42 | } 43 | 44 | @media (min-width: 640px) { 45 | :root { 46 | --vp-home-hero-image-filter: blur(56px); 47 | } 48 | } 49 | 50 | @media (min-width: 960px) { 51 | :root { 52 | --vp-home-hero-image-filter: blur(72px); 53 | } 54 | } 55 | 56 | .VPHome { 57 | display: flex; 58 | flex-direction: column; 59 | align-content: center; 60 | justify-content: center; 61 | } 62 | 63 | .VPHome > * { 64 | margin: 1rem auto; 65 | display: inline-block; 66 | } 67 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | import DefaultTheme from 'vitepress/theme' 2 | import { h } from 'vue' 3 | import HomePage from './HomePage.vue'; 4 | import './custom.css' 5 | 6 | export default { 7 | ...DefaultTheme, 8 | Layout() { 9 | return h(DefaultTheme.Layout, null, { 10 | 'home-features-after': () => h(HomePage), 11 | }) 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar: false 3 | nav: false 4 | layout: home 5 | 6 | title: The Fun Framework 7 | titleTemplate: An experimental homegrown JS framework. 8 | --- 9 | 10 |
11 | 12 | ```html 13 | 14 | 15 | 16 |
17 |

Count

18 | 19 |

Value: {{count.value}}

20 |

{{count.value}} is even

21 |

{{count.value}} is odd

22 |
23 |
24 |

Names

25 |
    26 |
  • {{item.name}}
  • 27 |
28 | 29 |
30 | ``` 31 | 32 | ```typescript 33 | // index.ts 34 | import { createState, registerComponent, render } from "the-fun-framework"; 35 | 36 | function Count() { 37 | const count = createState(0); 38 | 39 | function updateCount() { 40 | count.value++; 41 | } 42 | 43 | return { 44 | count, 45 | updateCount, 46 | }; 47 | } 48 | 49 | Count.selector = "Count"; 50 | registerComponent(Count); 51 | 52 | function People() { 53 | const list = createState([ 54 | { 55 | name: "Corbin", 56 | key: "corbin", 57 | }, 58 | { 59 | name: "Ade", 60 | key: "ade", 61 | }, 62 | ]); 63 | 64 | let personCount = 0; 65 | function addPerson() { 66 | const newList = [...list.value]; 67 | ++personCount; 68 | newList.push({ 69 | name: `Person ${personCount}`, 70 | key: `person_${personCount}`, 71 | }); 72 | list.value = newList; 73 | } 74 | 75 | return { 76 | list, 77 | addPerson, 78 | }; 79 | } 80 | 81 | People.selector = "People"; 82 | registerComponent(People); 83 | render(); 84 | ``` 85 | 86 |
87 | -------------------------------------------------------------------------------- /example/main.ts: -------------------------------------------------------------------------------- 1 | import { createState, registerComponent, render } from "the-fun-framework"; 2 | 3 | function Count() { 4 | const count = createState(0); 5 | 6 | function updateCount() { 7 | count.value++; 8 | } 9 | 10 | return { 11 | count, 12 | updateCount, 13 | }; 14 | } 15 | 16 | Count.selector = "Count"; 17 | registerComponent(Count); 18 | 19 | function People() { 20 | const list = createState([ 21 | { 22 | name: "Corbin", 23 | key: "corbin", 24 | }, 25 | { 26 | name: "Ade", 27 | key: "ade", 28 | }, 29 | ]); 30 | 31 | let personCount = 0; 32 | function addPerson() { 33 | const newList = [...list.value]; 34 | ++personCount; 35 | newList.push({ 36 | name: `Person ${personCount}`, 37 | key: `person_${personCount}`, 38 | }); 39 | list.value = newList; 40 | } 41 | 42 | return { 43 | list, 44 | addPerson, 45 | }; 46 | } 47 | 48 | People.selector = "People"; 49 | registerComponent(People); 50 | render(); 51 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + TS 8 | 9 | 10 |
11 |
12 |

Count

13 | 14 |

Value: {{count.value}}

15 |

{{count.value}} is even

16 |

{{count.value}} is odd

17 |
18 |
19 |

Names

20 |
    21 |
  • 22 | {{item.name}} 23 |
  • 24 |
25 | 26 |
27 |
28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /lib/expression-parser.ts: -------------------------------------------------------------------------------- 1 | import jsep from "jsep"; 2 | import jsepAssignment from "@jsep-plugin/assignment"; 3 | import { BasicEval } from "espression"; 4 | import { walk } from "estree-walker"; 5 | 6 | jsep.plugins.register(jsepAssignment); 7 | 8 | export type Expression = jsep.Expression; 9 | 10 | const staticEval = new BasicEval(); 11 | 12 | export function parseExpression(expressionString: string) { 13 | return jsep(expressionString); 14 | } 15 | 16 | export function walkExpression( 17 | exp: jsep.Expression, 18 | fn: (exp: jsep.Expression) => void 19 | ) { 20 | walk(exp as never, { 21 | enter(node) { 22 | fn(node as jsep.Expression); 23 | }, 24 | }); 25 | } 26 | 27 | /** 28 | * Walks the expression, only persists the top-level identifiers 29 | */ 30 | export function walkParentExpression( 31 | parsedExp: jsep.Expression, 32 | ignoredExps: jsep.Expression[], 33 | listenerExps: jsep.Expression[] 34 | ) { 35 | walkExpression(parsedExp, (exp) => { 36 | if (ignoredExps.includes(exp)) { 37 | if (exp.type !== "MemberExpression") return; 38 | ignoredExps.push(exp.object as jsep.Expression); 39 | ignoredExps.push(exp.property as jsep.Expression); 40 | return; 41 | } 42 | if (exp.type === "MemberExpression") { 43 | listenerExps.push(exp.object as jsep.Expression); 44 | ignoredExps.push(exp.property as jsep.Expression); 45 | return; 46 | } 47 | if (exp.type === "Identifier") { 48 | listenerExps.push(exp); 49 | return; 50 | } 51 | }); 52 | } 53 | 54 | export function evaluateExpression( 55 | exp: jsep.Expression, 56 | data: Record 57 | ) { 58 | return staticEval.evaluate(exp, data); 59 | } 60 | -------------------------------------------------------------------------------- /lib/hooks.ts: -------------------------------------------------------------------------------- 1 | export function createState(initialValue: T): { 2 | listeners: Array<() => void>; 3 | value: T; 4 | } { 5 | let val = initialValue; 6 | const listeners = [] as Array<() => void>; 7 | return { 8 | get value() { 9 | return val; 10 | }, 11 | set value(v: T) { 12 | val = v; 13 | listeners.forEach((fn) => fn()); 14 | }, 15 | listeners, 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./hooks"; 2 | export * from "./renderer"; 3 | export type { FunComponent } from "./types"; 4 | -------------------------------------------------------------------------------- /lib/renderer.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from "vitest"; 2 | 3 | import userEvent from "@testing-library/user-event"; 4 | import { createState, registerComponent, render } from "the-fun-framework"; 5 | import { findByText, getByText, queryByText } from "@testing-library/dom"; 6 | 7 | const user = userEvent.setup(); 8 | 9 | describe("render", () => { 10 | test("should support rendering static values", async () => { 11 | document.body.innerHTML = ` 12 |
13 |

{{message}}

14 |
15 | `; 16 | 17 | function App() { 18 | return { 19 | message: "Hello, world", 20 | }; 21 | } 22 | 23 | App.selector = "App"; 24 | 25 | registerComponent(App); 26 | render(); 27 | expect(await findByText(document.body, "Hello, world")).toBeTruthy(); 28 | }); 29 | 30 | test("should support rendered values and updating them", async () => { 31 | document.body.innerHTML = ` 32 |
33 | 34 |

Count: {{count.value}}

35 |
36 | `; 37 | 38 | function App() { 39 | const count = createState(0); 40 | 41 | function updateCount() { 42 | count.value++; 43 | } 44 | 45 | return { 46 | count, 47 | updateCount, 48 | }; 49 | } 50 | 51 | App.selector = "App"; 52 | 53 | registerComponent(App); 54 | render(); 55 | 56 | expect(await findByText(document.body, "Count: 0")).toBeTruthy(); 57 | await user.click(getByText(document.body, "Add")); 58 | expect(await findByText(document.body, "Count: 1")).toBeTruthy(); 59 | }); 60 | 61 | test("should support conditionally rendering", async () => { 62 | document.body.innerHTML = ` 63 |
64 | 65 |

Count is greater than 0

66 |
67 | `; 68 | 69 | function App() { 70 | const count = createState(0); 71 | 72 | function updateCount() { 73 | count.value++; 74 | } 75 | 76 | return { 77 | count, 78 | updateCount, 79 | }; 80 | } 81 | 82 | App.selector = "App"; 83 | 84 | registerComponent(App); 85 | render(); 86 | 87 | expect( 88 | await queryByText(document.body, "Count is greater than 0") 89 | ).not.toBeTruthy(); 90 | user.click(getByText(document.body, "Add")); 91 | expect( 92 | await findByText(document.body, "Count is greater than 0") 93 | ).toBeTruthy(); 94 | }); 95 | 96 | test("should support conditionally rendering multiple items", async () => { 97 | document.body.innerHTML = ` 98 |
99 | 100 |

Count is greater than 0

101 |

Count is 0

102 |
103 | `; 104 | 105 | function App() { 106 | const count = createState(0); 107 | 108 | function updateCount() { 109 | count.value++; 110 | } 111 | 112 | return { 113 | count, 114 | updateCount, 115 | }; 116 | } 117 | 118 | App.selector = "App"; 119 | 120 | registerComponent(App); 121 | render(); 122 | 123 | expect(await findByText(document.body, "Count is 0")).toBeTruthy(); 124 | await user.click(getByText(document.body, "Add")); 125 | expect( 126 | await findByText(document.body, "Count is greater than 0") 127 | ).toBeTruthy(); 128 | }); 129 | 130 | test("should support for loop rendering", async () => { 131 | document.body.innerHTML = ` 132 |
133 |

{{item.val}}

134 |
135 | `; 136 | 137 | function App() { 138 | return { 139 | items: [ 140 | { key: 1, val: "Hello" }, 141 | { key: 2, val: "Goodbye" }, 142 | ], 143 | }; 144 | } 145 | 146 | App.selector = "App"; 147 | 148 | registerComponent(App); 149 | render(); 150 | 151 | expect(await findByText(document.body, "Hello")).toBeTruthy(); 152 | expect(await findByText(document.body, "Goodbye")).toBeTruthy(); 153 | }); 154 | 155 | test("should support for loop rendering with other elements present", async () => { 156 | document.body.innerHTML = ` 157 |
158 |
Before
159 |

{{item.val}}

160 |
After
161 |
162 | `; 163 | 164 | function App() { 165 | return { 166 | items: [ 167 | { key: 1, val: "Hello" }, 168 | { key: 2, val: "Goodbye" }, 169 | ], 170 | }; 171 | } 172 | 173 | App.selector = "App"; 174 | 175 | registerComponent(App); 176 | render(); 177 | 178 | expect(await findByText(document.body, "Before")).toBeTruthy(); 179 | expect(await findByText(document.body, "Hello")).toBeTruthy(); 180 | expect(await findByText(document.body, "Goodbye")).toBeTruthy(); 181 | expect(await findByText(document.body, "After")).toBeTruthy(); 182 | }); 183 | 184 | test("should support for loop rendering two loops side-by-side", async () => { 185 | document.body.innerHTML = ` 186 |
187 |

{{item.val}}

188 |

{{item.val}}

189 |
190 | `; 191 | 192 | function App() { 193 | return { 194 | items: [ 195 | { key: 1, val: "Hello" }, 196 | { key: 2, val: "Goodbye" }, 197 | ], 198 | otheritems: [ 199 | { key: 1, val: "Other" }, 200 | { key: 2, val: "One" }, 201 | ], 202 | }; 203 | } 204 | 205 | App.selector = "App"; 206 | 207 | registerComponent(App); 208 | render(); 209 | 210 | expect(await findByText(document.body, "Hello")).toBeTruthy(); 211 | expect(await findByText(document.body, "Goodbye")).toBeTruthy(); 212 | expect(await findByText(document.body, "Other")).toBeTruthy(); 213 | expect(await findByText(document.body, "One")).toBeTruthy(); 214 | }); 215 | 216 | test("should support for dynamic loop rendering", async () => { 217 | document.body.innerHTML = ` 218 |
219 |
220 |

{{person.name}}

221 |
222 | 223 |
224 | `; 225 | 226 | function App() { 227 | const people = createState([ 228 | { 229 | name: "Corbin", 230 | key: "corbin", 231 | }, 232 | { 233 | name: "Ade", 234 | key: "ade", 235 | }, 236 | ]); 237 | 238 | let personCount = 0; 239 | function addPerson() { 240 | const newList = [...people.value]; 241 | ++personCount; 242 | newList.push({ 243 | name: `Person ${personCount}`, 244 | key: `person_${personCount}`, 245 | }); 246 | people.value = newList; 247 | } 248 | 249 | return { 250 | people, 251 | addPerson, 252 | }; 253 | } 254 | 255 | App.selector = "App"; 256 | 257 | registerComponent(App); 258 | render(); 259 | 260 | expect(await findByText(document.body, "Corbin")).toBeTruthy(); 261 | expect(await findByText(document.body, "Ade")).toBeTruthy(); 262 | await user.click(getByText(document.body, "Add person")); 263 | expect(await findByText(document.body, "Person 1")).toBeTruthy(); 264 | await user.click(getByText(document.body, "Add person")); 265 | expect(await findByText(document.body, "Person 2")).toBeTruthy(); 266 | await user.click(getByText(document.body, "Add person")); 267 | expect(await findByText(document.body, "Person 3")).toBeTruthy(); 268 | }); 269 | }); 270 | -------------------------------------------------------------------------------- /lib/renderer.ts: -------------------------------------------------------------------------------- 1 | import { FunComponent } from "./types"; 2 | import { 3 | evaluateExpression, 4 | Expression, 5 | parseExpression, 6 | walkParentExpression, 7 | } from "./expression-parser"; 8 | import { createState } from "./hooks"; 9 | 10 | const elements = {} as Record; 11 | 12 | export function registerComponent(comp: FunComponent) { 13 | elements[comp.selector] = comp; 14 | } 15 | 16 | const isHTMLElement = (node: ChildNode): node is HTMLElement => 17 | node.nodeType === node.ELEMENT_NODE; 18 | 19 | const textBindRegex = /\{\{(.*?)\}\}/g; 20 | 21 | function bindAndHandleElement>( 22 | node: ChildNode, 23 | data: T 24 | ): boolean { 25 | if (node.nodeType === node.COMMENT_NODE) { 26 | return true; 27 | } 28 | if (node.nodeType === node.TEXT_NODE) { 29 | const dataKeys = Object.keys(data); 30 | const listenerExps = [] as Expression[]; 31 | // Easier to implement this than try to for loop it 32 | node.nodeValue?.replace(textBindRegex, (substring, varName) => { 33 | const parsedExp = parseExpression(varName); 34 | const ignoredExps = [] as Expression[]; 35 | walkParentExpression(parsedExp, ignoredExps, listenerExps); 36 | return substring; 37 | }); 38 | 39 | const originalNodeValue = node.nodeValue!; 40 | 41 | function updateText() { 42 | node.nodeValue = originalNodeValue?.replace( 43 | textBindRegex, 44 | (_, varName) => { 45 | const parsedExp = parseExpression(varName); 46 | return evaluateExpression(parsedExp, data).toString(); 47 | } 48 | ); 49 | } 50 | 51 | const boundListenerNames = [] as string[]; 52 | for (const exp of listenerExps) { 53 | const name = exp.name as never; 54 | if (boundListenerNames.includes(name)) continue; 55 | if (!dataKeys.includes(name)) continue; 56 | const state = data[name] as ReturnType; 57 | if (state.listeners) { 58 | state.listeners.push(updateText); 59 | boundListenerNames.push(name); 60 | } 61 | } 62 | updateText(); 63 | return true; 64 | } 65 | if (isHTMLElement(node)) { 66 | const dataKeys = Object.keys(node.dataset); 67 | for (const key of dataKeys) { 68 | if (key.startsWith("on")) { 69 | const name = key.replace(/^on([A-Z])/, (match) => 70 | match[2].toLowerCase() 71 | ); 72 | const fnNameWithCall = node.dataset[key]!; 73 | node.addEventListener(name, () => 74 | evaluateExpression(parseExpression(fnNameWithCall), data) 75 | ); 76 | continue; 77 | } 78 | if (key.startsWith("for")) { 79 | // "item of list" 80 | const listExpression = node.dataset[key]!; 81 | // item.key 82 | const keyExpression = node.dataset.key!; 83 | const parsedListExp = parseExpression(listExpression); 84 | // item 85 | const itemVarName = (parsedListExp.body! as Expression[]).shift()! 86 | .name as string; 87 | // of 88 | const _ofOrIn = (parsedListExp.body! as Expression[]).shift()!.name; 89 | 90 | const listenerListExps = [] as Expression[]; 91 | walkParentExpression(parsedListExp, [], listenerListExps); 92 | 93 | const keyExp = parseExpression(keyExpression); 94 | 95 | function extractKeys() { 96 | const keys: Array<{ key: string; val: unknown }> = []; 97 | 98 | const list: Array = evaluateExpression(parsedListExp, data); 99 | if (!Array.isArray(list)) 100 | throw "You must bind `data-for` to an array"; 101 | 102 | for (const item of list) { 103 | keys.push({ 104 | key: evaluateExpression(keyExp, { 105 | ...data, 106 | [itemVarName]: item, 107 | }), 108 | val: item, 109 | }); 110 | } 111 | return keys; 112 | } 113 | 114 | const template = node.outerHTML; 115 | const parent = node.parentElement!; 116 | const listStart = document.createComment("List start"); 117 | const listEnd = document.createComment("List end"); 118 | parent.insertBefore(listStart, node); 119 | parent.insertBefore(listEnd, node.nextSibling); 120 | 121 | function extractKeysAndRerender() { 122 | const keys = extractKeys(); 123 | const newEls: HTMLElement[] = []; 124 | const childNodes = [...parent.childNodes]; 125 | const listStartIndex = childNodes.indexOf(listStart); 126 | const listEndIndex = childNodes.indexOf(listEnd); 127 | for (const { val, key } of keys) { 128 | let child = childNodes.find((child, i) => { 129 | if (i < listStartIndex) return false; 130 | if (i > listEndIndex) return false; 131 | if (!isHTMLElement(child)) return false; 132 | if (child.dataset.for === listExpression) { 133 | if (child.dataset.key === key) { 134 | newEls.push(child); 135 | return true; 136 | } 137 | } 138 | return false; 139 | }) as HTMLElement | undefined; 140 | if (!child) { 141 | const el = document.createElement("div"); 142 | el.innerHTML = template; 143 | child = el.firstElementChild as HTMLElement; 144 | child.removeAttribute("data-for"); 145 | child.removeAttribute("data-key"); 146 | // Needed for reconciliation 147 | child.setAttribute("data-specific-key", key); 148 | bindAndHandleChildren([child], { 149 | ...data, 150 | [itemVarName]: val, 151 | }); 152 | } 153 | newEls.push(child); 154 | } 155 | 156 | const dynamicChildren = childNodes 157 | .slice(listStartIndex + 1, listEndIndex) 158 | .filter(isHTMLElement); 159 | const childOrNewElsLength = Math.max( 160 | dynamicChildren.length, 161 | newEls.length 162 | ); 163 | for (let i = 0; i < childOrNewElsLength; i++) { 164 | const child = dynamicChildren[i]; 165 | const newEl = newEls[i]; 166 | if ( 167 | child && 168 | newEl && 169 | child.dataset.specificKey === newEl.dataset.specificKey 170 | ) 171 | continue; 172 | if (child) { 173 | child.replaceWith(newEl); 174 | } else if (newEl) { 175 | parent.insertBefore(newEl, listEnd); 176 | } 177 | } 178 | } 179 | 180 | const boundListenerNames = [] as string[]; 181 | 182 | for (const exp of listenerListExps) { 183 | const name = exp.name as never; 184 | if (boundListenerNames.includes(name)) continue; 185 | if (!Object.keys(data).includes(name)) continue; 186 | const state = data[name] as ReturnType; 187 | if (!state.listeners) continue; 188 | state.listeners.push(extractKeysAndRerender); 189 | boundListenerNames.push(name); 190 | } 191 | 192 | extractKeysAndRerender(); 193 | return false; 194 | } 195 | if (key.startsWith("if")) { 196 | const expression = node.dataset[key]!; 197 | const parsedExp = parseExpression(expression); 198 | const listenerExps = [] as Expression[]; 199 | const ignoredExps = [] as Expression[]; 200 | walkParentExpression(parsedExp, ignoredExps, listenerExps); 201 | 202 | // TODO: This won't work if the previous sibling also changes or whatnot 203 | const previousSibling = node.previousElementSibling as HTMLElement; 204 | function checkAndConditionallyRender() { 205 | const shouldKeep = evaluateExpression(parsedExp, data); 206 | if (shouldKeep) { 207 | previousSibling.insertAdjacentElement( 208 | "afterend", 209 | node as HTMLElement 210 | ); 211 | return; 212 | } 213 | node.remove(); 214 | } 215 | 216 | const boundListenerNames = [] as string[]; 217 | for (const exp of listenerExps) { 218 | const name = exp.name as never; 219 | if (boundListenerNames.includes(name)) continue; 220 | if (!Object.keys(data).includes(name)) continue; 221 | const state = data[name] as ReturnType; 222 | if (!state.listeners) continue; 223 | state.listeners.push(checkAndConditionallyRender); 224 | boundListenerNames.push(name); 225 | } 226 | checkAndConditionallyRender(); 227 | } 228 | } 229 | } 230 | return true; 231 | } 232 | 233 | const handledElements = new WeakSet(); 234 | 235 | // Roots cannot bind anything 236 | function bindAndHandleChildren( 237 | children: NodeListOf | HTMLElement[], 238 | data: Record | undefined 239 | ) { 240 | for (const child of children) { 241 | if (handledElements.has(child)) continue; 242 | const shouldBindChildren = bindAndHandleElement(child, data!); 243 | handledElements.add(child); 244 | if (shouldBindChildren && child.childNodes.length) { 245 | bindAndHandleChildren(child.childNodes, data); 246 | } 247 | } 248 | } 249 | 250 | function _render(compName: string, rootEl: HTMLElement) { 251 | const data = elements[compName]?.(rootEl); 252 | 253 | bindAndHandleChildren(rootEl.childNodes, data); 254 | } 255 | 256 | export function render() { 257 | const roots = [ 258 | ...document.querySelectorAll("[data-island-comp]"), 259 | ] as HTMLElement[]; 260 | 261 | for (const root of roots) { 262 | _render(root.dataset.islandComp!, root); 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /lib/types.ts: -------------------------------------------------------------------------------- 1 | export type FunComponent = ((el: HTMLElement) => Record) & { 2 | selector: string; 3 | }; 4 | -------------------------------------------------------------------------------- /other/slide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crutchcorn/the-fun-framework/362fafaa3dfd9e594329d39fc40ad5c597e73c7e/other/slide.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "the-fun-framework", 3 | "version": "0.0.1-alpha.1", 4 | "description": "An experimental homegrown JS framework.", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/crutchcorn/the-fun-framework.git" 8 | }, 9 | "bugs": { 10 | "url": "https://github.com/crutchcorn/the-fun-framework/issues" 11 | }, 12 | "homepage": "https://crutchcorn.github.io/the-fun-framework/", 13 | "type": "commonjs", 14 | "scripts": { 15 | "dev": "vite", 16 | "build": "tsc && vite build", 17 | "preview": "vite preview", 18 | "lint": "eslint \"**/*.{js,jsx,ts,tsx}\"", 19 | "format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix", 20 | "docs:dev": "vitepress dev docs", 21 | "docs:build": "vitepress build docs", 22 | "docs:preview": "vitepress preview docs", 23 | "test": "vitest", 24 | "prepare": "husky install" 25 | }, 26 | "devDependencies": { 27 | "@commitlint/cli": "^17.6.3", 28 | "@commitlint/config-conventional": "^17.6.3", 29 | "@testing-library/dom": "^9.3.0", 30 | "@testing-library/jest-dom": "^5.16.5", 31 | "@testing-library/user-event": "^14.4.3", 32 | "@typescript-eslint/eslint-plugin": "^5.59.7", 33 | "@typescript-eslint/parser": "^5.59.2", 34 | "eslint": "^8.41.0", 35 | "eslint-config-prettier": "^8.8.0", 36 | "eslint-plugin-prettier": "^4.2.1", 37 | "eslint-plugin-react": "^7.32.2", 38 | "husky": "^8.0.3", 39 | "jsdom": "^22.1.0", 40 | "lint-staged": "^13.2.2", 41 | "prettier": "^2.8.8", 42 | "typescript": "^5.0.2", 43 | "vite": "^4.3.9", 44 | "vite-plugin-dts": "^2.3.0", 45 | "vitepress": "^1.0.0-beta.1", 46 | "vitest": "^0.31.1", 47 | "vue": "^3.3.4" 48 | }, 49 | "dependencies": { 50 | "@jsep-plugin/assignment": "^1.2.1", 51 | "espression": "^1.8.5", 52 | "estree-walker": "^3.0.3", 53 | "jsep": "^1.3.8" 54 | }, 55 | "lint-staged": { 56 | "*{.js,.jsx,.ts,.tsx}": "eslint --fix" 57 | }, 58 | "engines": { 59 | "node": ">=18.0.0", 60 | "npm": ">= 99999.0.0", 61 | "pnpm": ">= 8.0.0" 62 | }, 63 | "types": "./dist/index.d.ts", 64 | "module": "./dist/the-fun-framework.mjs", 65 | "main": "./dist/the-fun-framework.cjs", 66 | "files": [ 67 | "dist", 68 | "lib", 69 | "example", 70 | "README.md" 71 | ], 72 | "exports": { 73 | ".": { 74 | "types": "./dist/index.d.ts", 75 | "import": "./dist/the-fun-framework.mjs", 76 | "require": "./dist/the-fun-framework.cjs", 77 | "default": "./dist/the-fun-framework.cjs" 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "paths": { 18 | "the-fun-framework": ["./lib"] 19 | } 20 | }, 21 | "include": ["lib", "example"], 22 | "exclude": ["*.spec.tsx", "*.spec.ts"], 23 | "references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.spec.json" }] 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts", "vitest.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["lib/**/*.spec.tsx", "lib/**/*.spec.ts"], 4 | "compilerOptions": { 5 | "noEmit": false, 6 | "composite": true, 7 | "paths": { 8 | "the-fun-framework": ["./lib"] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import dts from "vite-plugin-dts"; 3 | import { resolve } from "path"; 4 | import { dirname } from "path"; 5 | import { fileURLToPath } from "url"; 6 | 7 | const __dirname = dirname(fileURLToPath(import.meta.url)); 8 | 9 | const getFileName = (prefix: string, format: string) => { 10 | switch (format) { 11 | case "es": 12 | case "esm": 13 | case "module": 14 | return `${prefix}.mjs`; 15 | case "cjs": 16 | case "commonjs": 17 | default: 18 | return `${prefix}.cjs`; 19 | } 20 | }; 21 | 22 | export default defineConfig({ 23 | plugins: [ 24 | dts({ 25 | entryRoot: resolve(__dirname, "./lib"), 26 | }), 27 | ], 28 | base: "/the-fun-framework", 29 | resolve: { 30 | alias: { 31 | "the-fun-framework": resolve(__dirname, "./lib"), 32 | }, 33 | }, 34 | build: { 35 | lib: { 36 | entry: resolve(__dirname, "lib/index.ts"), 37 | name: "TheFunFramework", 38 | fileName: (format, entryName) => getFileName("the-fun-framework", format), 39 | }, 40 | }, 41 | }); 42 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | import { resolve } from "path"; 3 | import { dirname } from "path"; 4 | import { fileURLToPath } from "url"; 5 | 6 | const __dirname = dirname(fileURLToPath(import.meta.url)); 7 | 8 | export default defineConfig({ 9 | test: { 10 | setupFiles: ["./config/setup-tests.ts"], 11 | environment: "jsdom", 12 | }, 13 | resolve: { 14 | alias: { 15 | "the-fun-framework": resolve(__dirname, "./lib"), 16 | }, 17 | }, 18 | }); 19 | --------------------------------------------------------------------------------