├── .github
└── FUNDING.yml
├── .gitignore
├── LICENSE.md
├── README.md
├── TestApp
├── .prettierignore
├── bun.lockb
├── fornax.config.ts
├── main.ts
├── package.json
├── src
│ ├── client
│ │ ├── app.component.ts
│ │ ├── assets
│ │ │ ├── logo.png
│ │ │ └── logo.svg
│ │ ├── components
│ │ │ ├── emitting.component.css
│ │ │ ├── emitting.component.html
│ │ │ ├── emitting.component.ts
│ │ │ ├── hello-world.component.ts
│ │ │ └── other.component.ts
│ │ ├── favicon.ico
│ │ ├── index.html
│ │ ├── routes.ts
│ │ └── services
│ │ │ └── api.service.ts
│ └── server
│ │ ├── controllers
│ │ └── event.controller.ts
│ │ └── models
│ │ └── event.ts
└── tsconfig.json
├── bun.lockb
├── core
├── BaseComponent.ts
├── Context.ts
├── Decorators.ts
├── EventEmitter.ts
├── LRUCache.ts
├── Models.ts
├── Parser.ts
├── Routing.ts
├── ServerDecorators.ts
├── ServerUtilities.ts
├── Template.ts
├── Utilities.ts
├── assets
│ ├── favicon.ico
│ ├── logo.png
│ └── logo.svg
├── schematics
│ ├── index.ts
│ └── templates.toml
├── scripts
│ ├── build.ts
│ ├── cli.ts
│ ├── client.ts
│ ├── constants.ts
│ ├── generate-imports.ts
│ ├── global-styles.ts
│ ├── live-reload.ts
│ ├── load-config.ts
│ └── server.ts
└── types
│ ├── global.d.ts
│ └── zod-extensions.d.ts
├── index.ts
├── package.json
├── server.ts
└── tsconfig.json
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [TBosak]
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
2 |
3 | # Logs
4 |
5 | logs
6 | _.log
7 | npm-debug.log_
8 | yarn-debug.log*
9 | yarn-error.log*
10 | lerna-debug.log*
11 | .pnpm-debug.log*
12 |
13 | # Caches
14 |
15 | .cache
16 |
17 | # Diagnostic reports (https://nodejs.org/api/report.html)
18 |
19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
20 |
21 | # Runtime data
22 |
23 | pids
24 | _.pid
25 | _.seed
26 | *.pid.lock
27 |
28 | # Directory for instrumented libs generated by jscoverage/JSCover
29 |
30 | lib-cov
31 |
32 | # Coverage directory used by tools like istanbul
33 |
34 | coverage
35 | *.lcov
36 |
37 | # nyc test coverage
38 |
39 | .nyc_output
40 |
41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
42 |
43 | .grunt
44 |
45 | # Bower dependency directory (https://bower.io/)
46 |
47 | bower_components
48 |
49 | # node-waf configuration
50 |
51 | .lock-wscript
52 |
53 | # Compiled binary addons (https://nodejs.org/api/addons.html)
54 |
55 | build/Release
56 |
57 | # Dependency directories
58 |
59 | node_modules/
60 | jspm_packages/
61 |
62 | # Snowpack dependency directory (https://snowpack.dev/)
63 |
64 | web_modules/
65 |
66 | # TypeScript cache
67 |
68 | *.tsbuildinfo
69 |
70 | # Optional npm cache directory
71 |
72 | .npm
73 |
74 | # Optional eslint cache
75 |
76 | .eslintcache
77 |
78 | # Optional stylelint cache
79 |
80 | .stylelintcache
81 |
82 | # Microbundle cache
83 |
84 | .rpt2_cache/
85 | .rts2_cache_cjs/
86 | .rts2_cache_es/
87 | .rts2_cache_umd/
88 |
89 | # Optional REPL history
90 |
91 | .node_repl_history
92 |
93 | # Output of 'npm pack'
94 |
95 | *.tgz
96 |
97 | # Yarn Integrity file
98 |
99 | .yarn-integrity
100 |
101 | # dotenv environment variable files
102 |
103 | .env
104 | .env.development.local
105 | .env.test.local
106 | .env.production.local
107 | .env.local
108 |
109 | # parcel-bundler cache (https://parceljs.org/)
110 |
111 | .parcel-cache
112 |
113 | # Next.js build output
114 |
115 | .next
116 | out
117 |
118 | # Nuxt.js build / generate output
119 |
120 | .nuxt
121 | dist
122 |
123 | # Gatsby files
124 |
125 | # Comment in the public line in if your project uses Gatsby and not Next.js
126 |
127 | # https://nextjs.org/blog/next-9-1#public-directory-support
128 |
129 | # public
130 |
131 | # vuepress build output
132 |
133 | .vuepress/dist
134 |
135 | # vuepress v2.x temp and cache directory
136 |
137 | .temp
138 |
139 | # Docusaurus cache and generated files
140 |
141 | .docusaurus
142 |
143 | # Serverless directories
144 |
145 | .serverless/
146 |
147 | # FuseBox cache
148 |
149 | .fusebox/
150 |
151 | # DynamoDB Local files
152 |
153 | .dynamodb/
154 |
155 | # TernJS port file
156 |
157 | .tern-port
158 |
159 | # Stores VSCode versions used for testing VSCode extensions
160 |
161 | .vscode-test
162 |
163 | # yarn v2
164 |
165 | .yarn/cache
166 | .yarn/unplugged
167 | .yarn/build-state.yml
168 | .yarn/install-state.gz
169 | .pnp.*
170 |
171 | # IntelliJ based IDEs
172 | .idea
173 |
174 | # Finder (MacOS) folder config
175 | .DS_Store
176 |
177 | #Test application
178 | TestApp/node_modules
179 | TestApp/dist
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Tim Barani
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |

4 |
5 |
Fornax
6 |
7 | 
8 | 
9 | 
10 | 
11 |
12 |
13 |
14 |
15 | Fornax is a lightweight, opinionated, and highly customizable Bun-powered full-stack web framework designed to simplify building single-page applications with custom components, routing, and flexible styling options.
16 |
17 |
18 | **Key Features** 🔑
19 | - **Custom Components** 🧩: Define reusable UI elements using decorators and TypeScript classes.
20 | - **Routing Made Easy** 🗺️: Leverage a `` and a straightforward `routes.ts` configuration for SPA navigation.
21 | - **Flexible Styling Modes** 🎨: Choose between scoped and global styling for your components.
22 | - **TypeScript by Default** 💻: Enjoy type safety and clean code with TypeScript integration.
23 |
24 | ---
25 |
26 | ## Getting Started 🏁
27 |
28 | ### Prerequisites ✅
29 | - **Bun** 🍞: Install Bun from [https://bun.sh/](https://bun.sh/)
30 |
31 | ### Installation ⚙️
32 | Create a new Fornax project:
33 | ```bash
34 | bunx fnx generate project
35 | ```
36 | OR just:
37 | ```bash
38 | bunx fnx
39 | ```
40 | Then follow the prompts to generate from schematics.
41 |
42 | If adding Fornax to your existing Bun project:
43 |
44 | ```bash
45 | bun add fornaxjs
46 | ```
47 |
48 | Create a `fornax.config.ts` in your project’s root to configure directories, ports, custom plugins (style-loader is included by default for css imports), and extra entry points:
49 |
50 | ```typescript
51 | export default {
52 | Client: {
53 | srcDir: "./src/client",
54 | distDir: "./dist",
55 | port: 5000,
56 | plugins: [],
57 | entryPoints: [],
58 | alternateStyleLoader: null,
59 | },
60 | Server: {
61 | dir: "./src/server",
62 | port: 5500,
63 | },
64 | };
65 |
66 | ```
67 |
68 | Adjust as needed.
69 |
70 | ### Project Structure 🗂️
71 |
72 | A typical Fornax project might look like this:
73 |
74 | ```
75 | project/
76 | ├─ src/
77 | | ├─ client/
78 | │ | ├─ index.html
79 | │ | ├─ routes.ts
80 | │ | ├─ app.component.ts
81 | │ │ ├─ components/
82 | │ │ │ ├─ some.component.ts
83 | │ │ │ ├─ other.component.ts
84 | │ │ ├─ assets/
85 | | | ├─ services/
86 | | ├─ server/
87 | | | ├─ controllers/
88 | | | | ├─ some.controller.ts
89 | | | ├─ models/
90 | | | | ├─ some.ts
91 | ├─ fornax.config.ts
92 | └─ main.ts
93 | ```
94 |
95 | - `index.html`: Your application’s HTML entry point.
96 | - `main.ts`: Dynamically generated entry that imports all components and routes.
97 | - `routes.ts`: Defines the application’s client-side routes.
98 | - `app/components/`: Store your custom components here.
99 |
100 | ### Running the Dev Server 🔧
101 |
102 | ```bash
103 | fnx dev
104 | ```
105 |
106 | This starts:
107 |
108 | - Bun as a back-end/static server with watch mode.
109 |
110 | ### Building for Production 🏗️
111 |
112 | ```bash
113 | fnx build
114 | ```
115 |
116 | Outputs bundled files into the `dist` directory.
117 |
118 | ### Starting the App 🏃
119 |
120 | After building, start the server without watch mode:
121 |
122 | ```bash
123 | fnx start
124 | ```
125 |
126 | Open `http://localhost:5000` to view your application.
127 |
128 | ---
129 |
130 | ## Styling Modes 🎨
131 |
132 | Fornax supports two style modes for your components:
133 |
134 | - **Scoped:** `
10 |
11 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/TestApp/src/client/components/emitting.component.css:
--------------------------------------------------------------------------------
1 | h2 {
2 | color: brown !important;
3 | }
4 |
--------------------------------------------------------------------------------
/TestApp/src/client/components/emitting.component.html:
--------------------------------------------------------------------------------
1 | Testing
2 |
3 |
4 |
7 |
--------------------------------------------------------------------------------
/TestApp/src/client/components/emitting.component.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Component,
3 | BaseComponent,
4 | ViewChild,
5 | EventEmitter,
6 | Output,
7 | } from "fornaxjs";
8 | import html from "./emitting.component.html" with { type: "text" };
9 | import styles from "./emitting.component.css";
10 |
11 | @Component({
12 | selector: "app-emitting",
13 | template: html,
14 | style: styles,
15 | })
16 | export class Emitting extends BaseComponent {
17 | @ViewChild("#clickMe") clickMe!: HTMLButtonElement;
18 | @Output() buttonClicked!: EventEmitter;
19 | txtHidden = false;
20 | listOfItems = ["Item 1", "Item 2", "Item 3"];
21 |
22 | onInit(): void {
23 | this.clickMe?.addEventListener("click", this.handleClick.bind(this));
24 | }
25 |
26 | handleClick(): void {
27 | this.txtHidden = !this.txtHidden;
28 | console.log("Woah!");
29 | this.buttonClicked.emit("Woah!");
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/TestApp/src/client/components/hello-world.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, BaseComponent, Context } from "fornaxjs";
2 | import { ApiService } from "../services/api.service";
3 |
4 | @Component({
5 | selector: "hello-world",
6 | template: `
7 |
8 | Hello {{ name }}!
9 | {{ apiResponse }}
10 | Star us on GitHub!
13 | `,
14 | style: `
15 | img { width: 10em; margin-bottom: 2em; }
16 | h1 { color: blue !important; }
17 | p { font-family: simport { const } from './../../../node_modules/bun-types/bun.d';
18 | ans-serif; }
19 | #bottom-corner { position: fixed; bottom: 0; right: 0; padding: 1em; background: #333; color: white; text-decoration: none; }
20 | `,
21 | })
22 | export class HelloWorld extends BaseComponent {
23 | name = "World";
24 | apiResponse = "Loading...";
25 | names: string[] = ["World", "GitHub", "Reddit", "Friends"];
26 | interval: any = setInterval(() => this.cycleNames(), 2000);
27 |
28 | cycleNames() {
29 | let name = this.names.shift() as string;
30 | this.names.push(name);
31 | this.name = name;
32 | }
33 |
34 | onInit(): void {
35 | const apiService: ApiService = Context.get("ApiService");
36 | this.apiResponse = apiService.getData();
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/TestApp/src/client/components/other.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, BaseComponent } from "fornaxjs";
2 | // prettier-ignore
3 | @Component({
4 | selector: "app-other",
5 | template: `
6 | Other Page
7 | This is the other page, accessible at "/other".
8 |
9 | `,
10 | style: `
11 | h1 { color: green !important; }
12 | p { font-family: sans-serif; }
13 | `,
14 | })
15 | export class Other extends BaseComponent {
16 | logClick(event: CustomEvent): void {
17 | console.log("Route params:", this.params); // Debug log
18 | console.log("Button clicked! Event detail:", event.detail);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/TestApp/src/client/favicon.ico:
--------------------------------------------------------------------------------
1 |
2 |
3 |
18 |
--------------------------------------------------------------------------------
/TestApp/src/client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Welcome
6 |
7 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/TestApp/src/client/routes.ts:
--------------------------------------------------------------------------------
1 | import { addRouter } from "fornaxjs";
2 | import { HelloWorld } from "./components/hello-world.component";
3 | import { Other } from "./components/other.component";
4 |
5 | export const routes: any[] = [
6 | { path: "/", component: HelloWorld },
7 | { path: "/other/:id", component: Other },
8 | { path: "/test", component: Other, canActivate: myCustomGuard },
9 | ];
10 |
11 | addRouter("router-outlet", routes);
12 |
13 | function myCustomGuard(context: any, commands: any) {
14 | alert("You are not allowed here!");
15 | return commands.redirect("/other/1");
16 | }
17 |
--------------------------------------------------------------------------------
/TestApp/src/client/services/api.service.ts:
--------------------------------------------------------------------------------
1 | import { Service } from "fornaxjs";
2 |
3 | @Service("ApiService")
4 | export class ApiService {
5 | getData() {
6 | return "Welcome to Fornax!";
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/TestApp/src/server/controllers/event.controller.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Controller,
3 | Get,
4 | ControllerBase,
5 | Post,
6 | type Context,
7 | } from "fornaxjs/server";
8 | import { Event } from "../models/event";
9 |
10 | @Controller("/events")
11 | export class EventController extends ControllerBase {
12 | @Post("/", { body: Event }, Event)
13 | async createEvent(ctx: Context) {
14 | const body = await ctx.req.json();
15 | return this.Ok(ctx, body);
16 | }
17 |
18 | @Get("/:id", { params: Number }, Event)
19 | async getEvent(ctx: Context) {
20 | const id = ctx.req.param("id");
21 | return this.Ok(ctx, {
22 | id,
23 | name: "Fornax Launch Party",
24 | startTime: "2023-12-21T15:30:00Z",
25 | attendees: 50,
26 | });
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/TestApp/src/server/models/event.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Model,
3 | String,
4 | Number,
5 | ISODate,
6 | OptionalISODate,
7 | } from "fornaxjs/server";
8 |
9 | @Model()
10 | export class Event {
11 | @String({ example: "1", description: "Unique identifier for the event" })
12 | id!: string;
13 |
14 | @String({ example: "Fornax Launch Party", description: "Event name" })
15 | name!: string;
16 |
17 | @ISODate({
18 | example: "2023-12-21T15:30:00Z",
19 | description: "Event start date and time",
20 | })
21 | startTime!: string;
22 |
23 | @OptionalISODate({
24 | example: "2023-12-22T15:30:00Z",
25 | description: "Event end date and time",
26 | })
27 | endTime?: string;
28 |
29 | @Number({ example: 50, description: "Number of attendees expected" })
30 | attendees!: number;
31 | }
32 |
--------------------------------------------------------------------------------
/TestApp/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | // Enable latest features
4 | "lib": ["ESNext", "DOM"],
5 | "target": "ESNext",
6 | "module": "ESNext",
7 | "moduleDetection": "force",
8 | "jsx": "react-jsx",
9 | "allowJs": true,
10 | "experimentalDecorators": true,
11 | "emitDecoratorMetadata": true,
12 | "typeRoots": ["./node_modules/fornaxjs/core/types"],
13 | // Bundler mode
14 | "moduleResolution": "bundler",
15 | "allowImportingTsExtensions": true,
16 | "verbatimModuleSyntax": true,
17 | "noEmit": true,
18 |
19 | // Best practices
20 | "strict": true,
21 | "skipLibCheck": true,
22 | "noFallthroughCasesInSwitch": true,
23 |
24 | // Some stricter flags (disabled by default)
25 | "noUnusedLocals": false,
26 | "noUnusedParameters": false,
27 | "noPropertyAccessFromIndexSignature": false
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TBosak/fornax/0c331e5f2bb631a3205252762aa44da816fd1b3b/bun.lockb
--------------------------------------------------------------------------------
/core/BaseComponent.ts:
--------------------------------------------------------------------------------
1 | import { Template } from "./Template";
2 | import { Parser } from "./Parser";
3 | import { toCamelCase, toKebabCase } from "./Utilities";
4 | import { Binding, ComponentConfig } from "./Models";
5 | import { globalStyles } from "./scripts/global-styles";
6 |
7 | export class BaseComponent extends HTMLElement {
8 | public __config: ComponentConfig;
9 | template: Template;
10 | model: any;
11 | static inputs: string[] = [];
12 | static outputs: string[] = [];
13 | private _shadow: ShadowRoot;
14 | private renderScheduled: boolean = false;
15 | private reactivePropsCache = new Map();
16 | private observer: IntersectionObserver;
17 | private _isConnected = false;
18 | private idleCallbackId: number | null = null;
19 | public params: Readonly>;
20 |
21 | connectedCallback() {
22 | this._isConnected = true;
23 | this._shadow = this.attachShadow({ mode: "open" });
24 | this.initializeComponent();
25 |
26 | // Observe visibility changes
27 | this.observer = new IntersectionObserver(
28 | (entries) => {
29 | entries.forEach((entry) => {
30 | this._isConnected = entry.isIntersecting;
31 | if (!entry.isIntersecting) {
32 | cancelIdleCallback(this.idleCallbackId);
33 | }
34 | });
35 | },
36 | { threshold: 0.1 },
37 | );
38 |
39 | this.observer.observe(this);
40 |
41 | requestAnimationFrame(() => {
42 | setTimeout(() => {
43 | if (typeof this.onInit === "function") {
44 | this.onInit();
45 | }
46 | }, 0);
47 | });
48 | }
49 |
50 | private async initializeComponent() {
51 | const globalCSS =
52 | this.__config.styleMode !== "scoped" ? await globalStyles : "";
53 | const combinedStyles =
54 | this.__config.styleMode === "scoped"
55 | ? this.__config.style
56 | : `${this.__config.style || ""}\n${globalCSS}`;
57 |
58 | if (this.__config.template) {
59 | this.__config.style = combinedStyles;
60 | this.init();
61 | } else {
62 | console.warn("Template is not defined for the component.");
63 | }
64 | }
65 |
66 | disconnectedCallback() {
67 | this._isConnected = false;
68 |
69 | // Cancel any pending requestIdleCallback
70 | if (this.idleCallbackId !== null) {
71 | cancelIdleCallback(this.idleCallbackId);
72 | this.idleCallbackId = null;
73 | }
74 |
75 | if (typeof this.onDestroy === "function") {
76 | this.onDestroy();
77 | }
78 | }
79 |
80 | private init(): void {
81 | this.template = new Template(this.__config.template!);
82 | this.setupReactiveProperties();
83 | this.render(true);
84 | }
85 |
86 | private extractTemplateProperties(template: string): string[] {
87 | if (this.reactivePropsCache.has(template)) {
88 | return this.reactivePropsCache.get(template)!;
89 | }
90 |
91 | const propertyRegex = /{{\s*([a-zA-Z0-9_]+)\s*}}/g; // Match {{ prop }}
92 | const ifDirectiveRegex = /\*if="([^"]+)"/g; // Match *if="condition"
93 | const forDirectiveRegex = /\*for="([^"]+)\s+of\s+([^"]+)"/g; // Match *for="item of collection"
94 |
95 | const matches = new Set();
96 |
97 | // Extract properties from {{ }} bindings
98 | let match;
99 | while ((match = propertyRegex.exec(template)) !== null) {
100 | matches.add(match[1]);
101 | }
102 |
103 | // Extract properties from *if directives
104 | while ((match = ifDirectiveRegex.exec(template)) !== null) {
105 | const condition = match[1];
106 | const conditionProps = condition.match(/[a-zA-Z0-9_]+/g); // Extract individual properties
107 | if (conditionProps) {
108 | conditionProps.forEach((prop) => matches.add(prop));
109 | }
110 | }
111 |
112 | // Extract properties from *for directives
113 | while ((match = forDirectiveRegex.exec(template)) !== null) {
114 | const [, item, collection] = match;
115 | matches.add(item); // Add the item variable
116 | matches.add(collection); // Add the collection variable
117 | }
118 |
119 | const props = Array.from(matches);
120 | this.reactivePropsCache.set(template, props);
121 | return props;
122 | }
123 |
124 | private setupReactiveProperties(): void {
125 | if (!this.__config?.template) return;
126 |
127 | const reactiveProps = this.extractTemplateProperties(
128 | this.__config.template,
129 | );
130 | const proto = Object.getPrototypeOf(this);
131 |
132 | reactiveProps.forEach((key) => {
133 | if (typeof this[key] !== "function" && !key.startsWith("__")) {
134 | let internalValue = this[key];
135 |
136 | const descriptor =
137 | Object.getOwnPropertyDescriptor(this, key) ||
138 | Object.getOwnPropertyDescriptor(proto, key);
139 |
140 | if (!descriptor || (!descriptor.get && !descriptor.set)) {
141 | Object.defineProperty(this, key, {
142 | get: () => internalValue,
143 | set: (newVal) => {
144 | if (internalValue !== newVal) {
145 | internalValue = newVal;
146 | this.setModel();
147 | this.scheduleRender();
148 | }
149 | },
150 | configurable: true,
151 | enumerable: true,
152 | });
153 | } else {
154 | // Respect existing getter and setter logic
155 | const originalGet = descriptor.get;
156 | const originalSet = descriptor.set;
157 |
158 | Object.defineProperty(this, key, {
159 | get: originalGet || (() => internalValue),
160 | set: (newVal) => {
161 | if (originalSet) {
162 | originalSet.call(this, newVal);
163 | } else {
164 | internalValue = newVal;
165 | }
166 | this.setModel();
167 | this.scheduleRender();
168 | },
169 | configurable: true,
170 | enumerable: true,
171 | });
172 | }
173 | }
174 | });
175 |
176 | this.model = {};
177 | this.setModel();
178 | }
179 |
180 | private setModel() {
181 | for (const key of Object.keys(this)) {
182 | if (typeof this[key] !== "function" && !key.startsWith("__")) {
183 | this.model[key] = this[key];
184 | }
185 | }
186 | }
187 |
188 | private scheduleRender(): void {
189 | if (!this._isConnected) return; // Prevent rendering if disconnected
190 |
191 | if (!this.renderScheduled) {
192 | this.renderScheduled = true;
193 |
194 | requestAnimationFrame(() => {
195 | // Cancel any previous idle task before scheduling a new one
196 | if (this.idleCallbackId !== null) {
197 | cancelIdleCallback(this.idleCallbackId);
198 | }
199 |
200 | this.idleCallbackId = requestIdleCallback(() => {
201 | this.processRender();
202 | this.idleCallbackId = null;
203 | });
204 | });
205 | }
206 | }
207 |
208 | private processRender(): void {
209 | if (!this._isConnected) return;
210 | this.render();
211 | this.renderScheduled = false;
212 | }
213 |
214 | private async render(initial: boolean = false): Promise {
215 | if (!this._shadow) {
216 | console.error("Shadow root is not attached.");
217 | return;
218 | }
219 |
220 | const parser = Parser.sharedInstance();
221 | const renderResult = this.template.render(this.model, this) as [
222 | string,
223 | Binding[],
224 | ];
225 | const [templateString, bindings] = renderResult;
226 |
227 | const patchFn = parser.createPatch(templateString);
228 |
229 | const processChunk = () => {
230 | try {
231 | patchFn(this._shadow);
232 | } catch (error) {
233 | console.error("Error rendering component", error);
234 | }
235 | };
236 |
237 | // Clear `_renderComplete` before starting a new render
238 | if (initial) {
239 | const sheet = new CSSStyleSheet();
240 | sheet.replaceSync(this.__config.style || "");
241 | this._shadow.adoptedStyleSheets = [sheet];
242 | bindings.forEach(({ eventName, handlerName }) => {
243 | this.addEventListener(eventName, (event) => {
244 | const handler = this[handlerName];
245 | if (typeof handler === "function") {
246 | handler.call(this, event);
247 | } else {
248 | console.warn(
249 | `Handler '${handlerName}' is not defined in component:`,
250 | this,
251 | );
252 | }
253 | });
254 | });
255 | }
256 |
257 | processChunk();
258 | if (typeof this.onRenderComplete === "function") {
259 | this.onRenderComplete();
260 | }
261 | }
262 |
263 | static get observedAttributes() {
264 | return this.inputs.map(toKebabCase);
265 | }
266 |
267 | attributeChangedCallback(name: string, oldValue: string, newValue: string) {
268 | const propName = toCamelCase(name);
269 | if ((this.constructor as typeof BaseComponent).inputs.includes(propName)) {
270 | this[propName] = newValue;
271 | }
272 | }
273 |
274 | onInit(): void {}
275 | onDestroy(): void {}
276 | onRenderComplete(): void {}
277 | }
278 |
--------------------------------------------------------------------------------
/core/Context.ts:
--------------------------------------------------------------------------------
1 | type ServiceFactory = () => T;
2 |
3 | export class Context {
4 | private static context = new Map>();
5 | private static instances = new Map(); // Cache for service instances
6 |
7 | // Register a factory for a service
8 | static provide(key: string, factory: ServiceFactory): void {
9 | this.context.set(key, factory);
10 | }
11 |
12 | // Retrieve the service instance (lazy initialization)
13 | static get(key: string): T {
14 | // If the instance already exists, return it
15 | if (this.instances.has(key)) {
16 | return this.instances.get(key);
17 | }
18 |
19 | // Otherwise, create and cache the instance
20 | const factory = this.context.get(key);
21 | if (factory) {
22 | const instance = factory();
23 | this.instances.set(key, instance);
24 | return instance;
25 | }
26 |
27 | throw new Error(`Service ${key} is not registered`);
28 | }
29 |
30 | // Optional: Remove a service instance
31 | static remove(key: string): void {
32 | this.instances.delete(key);
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/core/Decorators.ts:
--------------------------------------------------------------------------------
1 | import { EventEmitter } from "./EventEmitter";
2 | import { ComponentConfig } from "./Models";
3 | import { Context } from "./Context";
4 | import "reflect-metadata";
5 |
6 | //Client Decorators
7 |
8 | export function Component(config: ComponentConfig) {
9 | return function (target: T) {
10 | // Create a class that extends the original target and stores the config
11 | const customElementClass = class extends target {
12 | __config = config;
13 |
14 | constructor(...args: any[]) {
15 | super(...args);
16 | }
17 | };
18 |
19 | // Define the custom element using the provided selector from config
20 | if (customElements.get(config.selector)) {
21 | // The custom element is defined
22 | } else {
23 | // The custom element is not defined
24 | customElements.define(config.selector, customElementClass);
25 | }
26 | target["selector"] = config.selector;
27 | // Return the newly extended and defined class
28 | return customElementClass;
29 | };
30 | }
31 |
32 | export function Input() {
33 | return function (target: any, propertyKey: string) {
34 | // Store the list of inputs on the class constructor
35 |
36 | if (!target.constructor.inputs) {
37 | target.constructor.inputs = [];
38 | }
39 | target.constructor.inputs.push(propertyKey);
40 |
41 | // Define getter and setter to observe property changes
42 |
43 | let value = target[propertyKey];
44 |
45 | Object.defineProperty(target, propertyKey, {
46 | get() {
47 | return value;
48 | },
49 | set(newVal) {
50 | value = newVal;
51 | if (typeof target.render === "function") {
52 | target.scheduleRender();
53 | }
54 | },
55 | enumerable: true,
56 | configurable: true,
57 | });
58 | };
59 | }
60 |
61 | export function Output(eventName?: string): PropertyDecorator {
62 | return function (target: any, propertyKey: string | symbol): void {
63 | const actualEventName = eventName || propertyKey.toString();
64 |
65 | Object.defineProperty(target, propertyKey, {
66 | get() {
67 | // Lazily initialize the Subject if it doesn't already exist
68 | if (!this[`__${String(propertyKey)}`]) {
69 | const emitter = new EventEmitter();
70 | emitter.subscribe((value: any) => {
71 | // Dispatch the event when the Subject emits
72 | const event = new CustomEvent(actualEventName, {
73 | detail: value,
74 | bubbles: true,
75 | composed: true, // Allow crossing shadow DOM boundaries
76 | });
77 | this.dispatchEvent(event);
78 | });
79 | this[`__${String(propertyKey)}`] = emitter;
80 | }
81 | return this[`__${String(propertyKey)}`];
82 | },
83 | set(newValue) {
84 | throw new Error(
85 | `Cannot overwrite @Output property '${String(propertyKey)}'.`
86 | );
87 | },
88 | configurable: true,
89 | enumerable: true,
90 | });
91 | };
92 | }
93 |
94 | export function Service(key: string) {
95 | return function (constructor: T) {
96 | const serviceKey = key;
97 |
98 | // Register a factory for the service
99 | Context.provide(serviceKey, () => new constructor());
100 | };
101 | }
102 |
103 | export function ViewChild(selector: string): PropertyDecorator {
104 | return function (target: any, propertyKey: string | symbol): void {
105 | const originalConnectedCallback = target.connectedCallback;
106 |
107 | target.connectedCallback = function (...args: any[]) {
108 | if (originalConnectedCallback) {
109 | originalConnectedCallback.apply(this, args);
110 | }
111 |
112 | const attemptToFindElement = () => {
113 | const element =
114 | this.shadowRoot?.querySelector(selector) ||
115 | this.querySelector(selector);
116 |
117 | if (element) {
118 | this[propertyKey] = element; // Direct assignment
119 | return;
120 | }
121 |
122 | console.warn(
123 | `@ViewChild: Element with selector '${selector}' not found. Retrying...`
124 | );
125 | requestAnimationFrame(attemptToFindElement);
126 | };
127 |
128 | // Defer query until the next frame
129 | requestAnimationFrame(attemptToFindElement);
130 | };
131 | };
132 | }
133 |
134 | export function ViewChildren(selector: string): PropertyDecorator {
135 | return function (target: any, propertyKey: string | symbol): void {
136 | const originalConnectedCallback = target.connectedCallback;
137 |
138 | target.connectedCallback = function (...args: any[]) {
139 | if (originalConnectedCallback) {
140 | originalConnectedCallback.apply(this, args);
141 | }
142 |
143 | const attemptToFindElement = () => {
144 | const element =
145 | this.shadowRoot?.querySelectorAll(selector) ||
146 | this.querySelectorAll(selector);
147 |
148 | if (element) {
149 | this[propertyKey] = element; // Direct assignment
150 | return;
151 | }
152 |
153 | console.warn(
154 | `@ViewChild: Element with selector '${selector}' not found. Retrying...`
155 | );
156 | requestAnimationFrame(attemptToFindElement);
157 | };
158 |
159 | // Defer query until the next frame
160 | requestAnimationFrame(attemptToFindElement);
161 | };
162 | };
163 | }
164 |
165 | // General Purpose Decorators
166 | //TEST FUNCTIONALITY
167 | export function Memoize() {
168 | return function (
169 | target: any,
170 | propertyKey: string,
171 | descriptor: PropertyDescriptor
172 | ) {
173 | const originalMethod = descriptor.value;
174 | const cacheKey = Symbol(`__memoize_${propertyKey}`);
175 |
176 | descriptor.value = function (...args: any[]) {
177 | const instance = this as { [key: symbol]: Map };
178 |
179 | if (!instance[cacheKey]) {
180 | instance[cacheKey] = new Map();
181 | }
182 |
183 | const cache = instance[cacheKey];
184 | const key = JSON.stringify(args);
185 |
186 | if (cache.has(key)) {
187 | return cache.get(key);
188 | }
189 |
190 | const result = originalMethod.apply(this, args);
191 | cache.set(key, result);
192 | return result;
193 | };
194 |
195 | return descriptor;
196 | };
197 | }
198 |
199 | const timeouts = new WeakMap