├── .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 |
11 |
12 |
13 |
An experimental homegrown JS framework.
14 |
15 |
16 |
17 |
18 |
19 | [](https://github.com/crutchcorn/the-fun-framework/actions/workflows/build.yml?query=branch%3Amain)
20 | [](https://github.com/crutchcorn/the-fun-framework/actions/workflows/test.yml?query=branch%3Amain)
21 | [](https://npm.im/the-fun-framework)
22 | [](https://unpkg.com/browse/the-fun-framework@latest/dist/the-fun-framework.cjs)
23 | [](./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 |
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 |
107 | - {{item.name}}
108 |
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 |
61 | Demo
62 |
63 |
Count
64 |
65 |
{{ `Count: \{\{count.value\}\}` }}
66 |
{{ `\{\{count.value\}\} is even` }}
67 |
68 |
69 |
Names
70 |
71 | -
72 | {{ `\{\{item.name\}\}` }}
73 |
74 |
75 |
76 |
77 | Code
78 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------