betal-fe 3.1.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +317 -0
- package/dist/betal-fe.js +421 -40
- package/package.json +16 -5
package/README.md
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
# Betal-FE
|
|
2
|
+
|
|
3
|
+
A lightweight, modern frontend framework implementing Virtual DOM, reactive components, routing, and slots from the ground up.
|
|
4
|
+
|
|
5
|
+
## 📦 Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install betal-fe
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## ✨ Features
|
|
12
|
+
|
|
13
|
+
- **Virtual DOM** - Efficient DOM updates through reconciliation
|
|
14
|
+
- **Component-Based** - Reusable UI components with props and state
|
|
15
|
+
- **Reactive State** - Automatic re-rendering on state changes
|
|
16
|
+
- **Lifecycle Hooks** - `onMounted`, `onUnmounted`, `onPropsChange`, `onStateChange`
|
|
17
|
+
- **Slots** - Content projection for flexible component composition (Vue-style)
|
|
18
|
+
- **Hash Router** - Built-in SPA routing with route guards and params
|
|
19
|
+
- **Event System** - Parent-child communication via emit/subscribe
|
|
20
|
+
- **Scheduler** - Async lifecycle execution with microtask batching
|
|
21
|
+
|
|
22
|
+
## 🚀 Quick Start
|
|
23
|
+
|
|
24
|
+
### Basic Counter Example
|
|
25
|
+
|
|
26
|
+
```javascript
|
|
27
|
+
import { createBetalApp, defineComponent, h } from 'betal-fe';
|
|
28
|
+
|
|
29
|
+
const Counter = defineComponent({
|
|
30
|
+
state() {
|
|
31
|
+
return { count: 0 };
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
render() {
|
|
35
|
+
return h("div", {}, [
|
|
36
|
+
h("p", {}, [`Count: ${this.state.count}`]),
|
|
37
|
+
h("button", {
|
|
38
|
+
on: { click: () => this.updateState({ count: this.state.count + 1 }) }
|
|
39
|
+
}, ["Increment"]),
|
|
40
|
+
]);
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
createBetalApp(Counter).mount(document.getElementById('app'));
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### With Routing
|
|
48
|
+
|
|
49
|
+
```javascript
|
|
50
|
+
import { createBetalApp, defineComponent, HashRouter, RouterLink, RouterOutlet, hFragment, h } from 'betal-fe';
|
|
51
|
+
|
|
52
|
+
const HomePage = defineComponent({
|
|
53
|
+
render() {
|
|
54
|
+
return h("h1", {}, ["Home Page"]);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const AboutPage = defineComponent({
|
|
59
|
+
render() {
|
|
60
|
+
return h("h1", {}, ["About Page"]);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const router = new HashRouter([
|
|
65
|
+
{ path: '/', component: HomePage },
|
|
66
|
+
{ path: '/about', component: AboutPage },
|
|
67
|
+
{ path: '/user/:id', component: UserPage },
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
const App = defineComponent({
|
|
71
|
+
render() {
|
|
72
|
+
return hFragment([
|
|
73
|
+
h("nav", {}, [
|
|
74
|
+
h(RouterLink, { to: '/' }, ['Home']),
|
|
75
|
+
h(RouterLink, { to: '/about' }, ['About']),
|
|
76
|
+
]),
|
|
77
|
+
h(RouterOutlet),
|
|
78
|
+
]);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
createBetalApp(App, {}, { router }).mount(document.getElementById('app'));
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## 📚 API Reference
|
|
86
|
+
|
|
87
|
+
### Core Functions
|
|
88
|
+
|
|
89
|
+
#### `createBetalApp(RootComponent, props, context)`
|
|
90
|
+
|
|
91
|
+
Creates and returns an application instance.
|
|
92
|
+
|
|
93
|
+
```javascript
|
|
94
|
+
const app = createBetalApp(MyApp, { title: 'Hello' }, { router });
|
|
95
|
+
app.mount(document.getElementById('app'));
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
#### `defineComponent(config)`
|
|
99
|
+
|
|
100
|
+
Defines a component with state, lifecycle hooks, and render method.
|
|
101
|
+
|
|
102
|
+
```javascript
|
|
103
|
+
const MyComponent = defineComponent({
|
|
104
|
+
state(props) {
|
|
105
|
+
return { count: 0 };
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
onMounted() {
|
|
109
|
+
// Called after component is mounted
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
onUnmounted() {
|
|
113
|
+
// Called before component is unmounted
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
onPropsChange(newProps, oldProps) {
|
|
117
|
+
// Called when props change
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
onStateChange(newState, oldState) {
|
|
121
|
+
// Called after state changes
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
render() {
|
|
125
|
+
return h("div", {}, [/* children */]);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
#### `h(tag, props, children)`
|
|
131
|
+
|
|
132
|
+
Creates a virtual DOM element.
|
|
133
|
+
|
|
134
|
+
```javascript
|
|
135
|
+
h("div", { class: "container", id: "app" }, [
|
|
136
|
+
h("h1", {}, ["Hello World"]),
|
|
137
|
+
h("button", { on: { click: handleClick } }, ["Click Me"])
|
|
138
|
+
]);
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
#### `hFragment(children)`
|
|
142
|
+
|
|
143
|
+
Creates a fragment node (renders children without wrapper element).
|
|
144
|
+
|
|
145
|
+
```javascript
|
|
146
|
+
hFragment([
|
|
147
|
+
h("h1", {}, ["Title"]),
|
|
148
|
+
h("p", {}, ["Content"])
|
|
149
|
+
]);
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
#### `hSlot(defaultContent)`
|
|
153
|
+
|
|
154
|
+
Creates a slot for content projection.
|
|
155
|
+
|
|
156
|
+
```javascript
|
|
157
|
+
const Card = defineComponent({
|
|
158
|
+
render() {
|
|
159
|
+
return h("div", { class: "card" }, [
|
|
160
|
+
h("h3", {}, [this.props.title]),
|
|
161
|
+
hSlot([h("p", {}, ["Default content"])])
|
|
162
|
+
]);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Usage
|
|
167
|
+
h(Card, { title: "My Card" }, [
|
|
168
|
+
h("p", {}, ["Custom content!"])
|
|
169
|
+
]);
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Component Instance Methods
|
|
173
|
+
|
|
174
|
+
#### `this.updateState(newState)`
|
|
175
|
+
|
|
176
|
+
Updates component state and triggers re-render.
|
|
177
|
+
|
|
178
|
+
```javascript
|
|
179
|
+
this.updateState({ count: this.state.count + 1 });
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
#### `this.emit(eventName, payload)`
|
|
183
|
+
|
|
184
|
+
Emits an event to parent component.
|
|
185
|
+
|
|
186
|
+
```javascript
|
|
187
|
+
this.emit('itemAdded', { id: 123, name: 'New Item' });
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Router
|
|
191
|
+
|
|
192
|
+
#### `new HashRouter(routes, options)`
|
|
193
|
+
|
|
194
|
+
Creates a hash-based router.
|
|
195
|
+
|
|
196
|
+
```javascript
|
|
197
|
+
const router = new HashRouter([
|
|
198
|
+
{ path: '/', component: HomePage },
|
|
199
|
+
{ path: '/user/:id', component: UserPage },
|
|
200
|
+
], {
|
|
201
|
+
beforeEnter: (to, from) => {
|
|
202
|
+
// Route guard - return false to cancel navigation
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
#### Router Context (in components)
|
|
209
|
+
|
|
210
|
+
```javascript
|
|
211
|
+
this.appContext.router.params // { id: '123' }
|
|
212
|
+
this.appContext.router.query // { tab: 'profile' }
|
|
213
|
+
this.appContext.router.navigateTo('/path')
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
#### `RouterLink` Component
|
|
217
|
+
|
|
218
|
+
```javascript
|
|
219
|
+
h(RouterLink, { to: '/about', activeClass: 'active' }, ['About'])
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
#### `RouterOutlet` Component
|
|
223
|
+
|
|
224
|
+
Renders the current route component.
|
|
225
|
+
|
|
226
|
+
```javascript
|
|
227
|
+
h(RouterOutlet)
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Event Dispatcher
|
|
231
|
+
|
|
232
|
+
#### Subscribe to Events
|
|
233
|
+
|
|
234
|
+
```javascript
|
|
235
|
+
this.appContext.dispatcher.subscribe('eventName', (payload) => {
|
|
236
|
+
console.log('Event received:', payload);
|
|
237
|
+
});
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
#### Dispatch Events
|
|
241
|
+
|
|
242
|
+
```javascript
|
|
243
|
+
this.emit('eventName', { data: 'value' });
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## 🎯 Component Lifecycle
|
|
247
|
+
|
|
248
|
+
1. **Constructor** → Component instance created
|
|
249
|
+
2. **state()** → Initial state computed
|
|
250
|
+
3. **render()** → Virtual DOM created
|
|
251
|
+
4. **Mount** → DOM elements created and inserted
|
|
252
|
+
5. **onMounted()** → Component is now in the DOM
|
|
253
|
+
6. **Props/State Change** → Re-render triggered
|
|
254
|
+
7. **onPropsChange() / onStateChange()** → After update
|
|
255
|
+
8. **onUnmounted()** → Before removal from DOM
|
|
256
|
+
9. **Unmount** → DOM cleanup
|
|
257
|
+
|
|
258
|
+
## 🔧 Props & Attributes
|
|
259
|
+
|
|
260
|
+
### Event Handlers
|
|
261
|
+
|
|
262
|
+
```javascript
|
|
263
|
+
h("button", {
|
|
264
|
+
on: {
|
|
265
|
+
click: (event) => console.log('clicked'),
|
|
266
|
+
input: (event) => this.updateState({ value: event.target.value })
|
|
267
|
+
}
|
|
268
|
+
}, ["Click Me"]);
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### Class Binding
|
|
272
|
+
|
|
273
|
+
```javascript
|
|
274
|
+
h("div", {
|
|
275
|
+
class: "container active",
|
|
276
|
+
// or
|
|
277
|
+
className: "container active"
|
|
278
|
+
}, [/* children */]);
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### Style Binding
|
|
282
|
+
|
|
283
|
+
```javascript
|
|
284
|
+
h("div", {
|
|
285
|
+
style: "color: red; font-size: 16px;"
|
|
286
|
+
}, [/* children */]);
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### Other Attributes
|
|
290
|
+
|
|
291
|
+
```javascript
|
|
292
|
+
h("input", {
|
|
293
|
+
type: "text",
|
|
294
|
+
value: this.state.inputValue,
|
|
295
|
+
placeholder: "Enter text",
|
|
296
|
+
disabled: true
|
|
297
|
+
});
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
## 📖 Examples
|
|
301
|
+
|
|
302
|
+
Check out the [GitHub repository](https://github.com/yourusername/betal-fe) for complete examples including:
|
|
303
|
+
- Todo app
|
|
304
|
+
|
|
305
|
+
## 🤝 Contributing
|
|
306
|
+
|
|
307
|
+
Contributions are welcome! Visit the [GitHub repository](https://github.com/yourusername/betal-fe) for contribution guidelines.
|
|
308
|
+
|
|
309
|
+
## 📄 License
|
|
310
|
+
|
|
311
|
+
MIT - Feel free to fork, modify, and build upon this project!
|
|
312
|
+
|
|
313
|
+
## 🔗 Links
|
|
314
|
+
|
|
315
|
+
- [GitHub Repository](https://github.com/SherlockHolmes07/betal-fe)
|
|
316
|
+
- [npm Package](https://www.npmjs.com/package/betal-fe)
|
|
317
|
+
- [Report Issues](https://github.com/SherlockHolmes07/betal-fe/issues)
|
package/dist/betal-fe.js
CHANGED
|
@@ -141,9 +141,12 @@ const DOM_TYPES = {
|
|
|
141
141
|
ELEMENT: "element",
|
|
142
142
|
FRAGMENT: "fragment",
|
|
143
143
|
COMPONENT: "component",
|
|
144
|
+
SLOT: "slot",
|
|
144
145
|
};
|
|
146
|
+
let hSlotCalled = false;
|
|
145
147
|
function h(tag, props = {}, children = []) {
|
|
146
|
-
const type =
|
|
148
|
+
const type =
|
|
149
|
+
typeof tag === "string" ? DOM_TYPES.ELEMENT : DOM_TYPES.COMPONENT;
|
|
147
150
|
return {
|
|
148
151
|
type,
|
|
149
152
|
tag,
|
|
@@ -152,9 +155,7 @@ function h(tag, props = {}, children = []) {
|
|
|
152
155
|
};
|
|
153
156
|
}
|
|
154
157
|
function mapTextNodes(nodes) {
|
|
155
|
-
return nodes.map((node) =>
|
|
156
|
-
typeof node === "string" ? hString(node) : node
|
|
157
|
-
);
|
|
158
|
+
return nodes.map((node) => (typeof node === "string" ? hString(node) : node));
|
|
158
159
|
}
|
|
159
160
|
function hString(str) {
|
|
160
161
|
return { type: DOM_TYPES.TEXT, value: str };
|
|
@@ -165,6 +166,16 @@ function hFragment(vNodes) {
|
|
|
165
166
|
children: mapTextNodes(withoutNulls(vNodes)),
|
|
166
167
|
};
|
|
167
168
|
}
|
|
169
|
+
function didCreateSlot() {
|
|
170
|
+
return hSlotCalled;
|
|
171
|
+
}
|
|
172
|
+
function resetDidCreateSlot() {
|
|
173
|
+
hSlotCalled = false;
|
|
174
|
+
}
|
|
175
|
+
function hSlot(children = []) {
|
|
176
|
+
hSlotCalled = true;
|
|
177
|
+
return { type: DOM_TYPES.SLOT, children };
|
|
178
|
+
}
|
|
168
179
|
function extractChildren(vdom) {
|
|
169
180
|
if (vdom.children == null) {
|
|
170
181
|
return [];
|
|
@@ -249,6 +260,39 @@ function removeEventListeners(listeners = {}, el) {
|
|
|
249
260
|
});
|
|
250
261
|
}
|
|
251
262
|
|
|
263
|
+
let isScheduled = false;
|
|
264
|
+
const jobs = [];
|
|
265
|
+
function enqueueJob(job) {
|
|
266
|
+
jobs.push(job);
|
|
267
|
+
scheduleUpdate();
|
|
268
|
+
}
|
|
269
|
+
function scheduleUpdate() {
|
|
270
|
+
if (isScheduled) return;
|
|
271
|
+
isScheduled = true;
|
|
272
|
+
queueMicrotask(processJobs);
|
|
273
|
+
}
|
|
274
|
+
function processJobs() {
|
|
275
|
+
while (jobs.length > 0) {
|
|
276
|
+
const job = jobs.shift();
|
|
277
|
+
const result = job();
|
|
278
|
+
Promise.resolve(result).then(
|
|
279
|
+
() => {
|
|
280
|
+
},
|
|
281
|
+
(error) => {
|
|
282
|
+
console.error(`[scheduler]: ${error}`);
|
|
283
|
+
}
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
isScheduled = false;
|
|
287
|
+
}
|
|
288
|
+
function nextTick() {
|
|
289
|
+
scheduleUpdate();
|
|
290
|
+
return flushPromises();
|
|
291
|
+
}
|
|
292
|
+
function flushPromises() {
|
|
293
|
+
return new Promise((resolve) => setTimeout(resolve));
|
|
294
|
+
}
|
|
295
|
+
|
|
252
296
|
function extractPropsAndEvents(vdom) {
|
|
253
297
|
const { on: events = {}, ...props } = vdom.props;
|
|
254
298
|
delete props.key;
|
|
@@ -267,6 +311,7 @@ function mountDOM(vDom, parentElement, index, hostComponent = null) {
|
|
|
267
311
|
}
|
|
268
312
|
case DOM_TYPES.COMPONENT: {
|
|
269
313
|
createComponentNode(vDom, parentElement, index, hostComponent);
|
|
314
|
+
enqueueJob(() => vDom.component.onMounted());
|
|
270
315
|
break;
|
|
271
316
|
}
|
|
272
317
|
case DOM_TYPES.FRAGMENT: {
|
|
@@ -320,9 +365,11 @@ function insert(el, parentEl, index) {
|
|
|
320
365
|
}
|
|
321
366
|
}
|
|
322
367
|
function createComponentNode(vdom, parentEl, index, hostComponent) {
|
|
323
|
-
const Component = vdom
|
|
368
|
+
const { tag: Component, children } = vdom;
|
|
324
369
|
const { props, events } = extractPropsAndEvents(vdom);
|
|
325
370
|
const component = new Component(props, events, hostComponent);
|
|
371
|
+
component.setExternalContent(children);
|
|
372
|
+
component.setAppContext(hostComponent?.appContext ?? {});
|
|
326
373
|
component.mount(parentEl, index);
|
|
327
374
|
vdom.component = component;
|
|
328
375
|
vdom.el = component.firstElement;
|
|
@@ -345,6 +392,7 @@ function destroyDOM(vDom) {
|
|
|
345
392
|
}
|
|
346
393
|
case DOM_TYPES.COMPONENT: {
|
|
347
394
|
vDom.component.unmount();
|
|
395
|
+
enqueueJob(() => vDom.component.onUnmounted());
|
|
348
396
|
break;
|
|
349
397
|
}
|
|
350
398
|
default: {
|
|
@@ -371,10 +419,259 @@ function removeFragmentNodes(vDom) {
|
|
|
371
419
|
children.forEach(destroyDOM);
|
|
372
420
|
}
|
|
373
421
|
|
|
374
|
-
|
|
422
|
+
class Dispatcher {
|
|
423
|
+
#subs = new Map();
|
|
424
|
+
#afterHandlers = [];
|
|
425
|
+
subscribe(commandName, handler) {
|
|
426
|
+
if (!this.#subs.has(commandName)) {
|
|
427
|
+
this.#subs.set(commandName, []);
|
|
428
|
+
}
|
|
429
|
+
const handlers = this.#subs.get(commandName);
|
|
430
|
+
if (handlers.includes(handler)) {
|
|
431
|
+
return () => {};
|
|
432
|
+
}
|
|
433
|
+
handlers.push(handler);
|
|
434
|
+
return () => {
|
|
435
|
+
const idx = handlers.indexOf(handler);
|
|
436
|
+
handlers.splice(idx, 1);
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
afterEveryCommand(handler) {
|
|
440
|
+
this.#afterHandlers.push(handler);
|
|
441
|
+
return () => {
|
|
442
|
+
const idx = this.#afterHandlers.indexOf(handler);
|
|
443
|
+
this.#afterHandlers.splice(idx, 1);
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
dispatch(commandName, payload) {
|
|
447
|
+
if (this.#subs.has(commandName)) {
|
|
448
|
+
this.#subs.get(commandName).forEach((handler) => handler(payload));
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
console.warn(`No handlers for command: ${commandName}`);
|
|
452
|
+
}
|
|
453
|
+
this.#afterHandlers.forEach((handler) => handler());
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const CATCH_ALL_ROUTE = "*";
|
|
458
|
+
function makeRouteMatcher(route) {
|
|
459
|
+
return routeHasParams(route)
|
|
460
|
+
? makeMatcherWithParams(route)
|
|
461
|
+
: makeMatcherWithoutParams(route);
|
|
462
|
+
}
|
|
463
|
+
function routeHasParams({ path }) {
|
|
464
|
+
return path.includes(":");
|
|
465
|
+
}
|
|
466
|
+
function makeMatcherWithParams(route) {
|
|
467
|
+
const regex = makeRouteWithParamsRegex(route);
|
|
468
|
+
const isRedirect = typeof route.redirect === "string";
|
|
469
|
+
return {
|
|
470
|
+
route,
|
|
471
|
+
isRedirect,
|
|
472
|
+
checkMatch(path) {
|
|
473
|
+
return regex.test(path);
|
|
474
|
+
},
|
|
475
|
+
extractParams(path) {
|
|
476
|
+
const { groups } = regex.exec(path);
|
|
477
|
+
return groups;
|
|
478
|
+
},
|
|
479
|
+
extractQuery,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
function makeRouteWithParamsRegex({ path }) {
|
|
483
|
+
const regex = path.replace(
|
|
484
|
+
/:([^/]+)/g,
|
|
485
|
+
(_, paramName) => `(?<${paramName}>[^/]+)`
|
|
486
|
+
);
|
|
487
|
+
return new RegExp(`^${regex}$`);
|
|
488
|
+
}
|
|
489
|
+
function makeMatcherWithoutParams(route) {
|
|
490
|
+
const regex = makeRouteWithoutParamsRegex(route);
|
|
491
|
+
const isRedirect = typeof route.redirect === "string";
|
|
492
|
+
return {
|
|
493
|
+
route,
|
|
494
|
+
isRedirect,
|
|
495
|
+
checkMatch(path) {
|
|
496
|
+
return regex.test(path);
|
|
497
|
+
},
|
|
498
|
+
extractParams() {
|
|
499
|
+
return {};
|
|
500
|
+
},
|
|
501
|
+
extractQuery,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
function makeRouteWithoutParamsRegex({ path }) {
|
|
505
|
+
if (path === CATCH_ALL_ROUTE) {
|
|
506
|
+
return new RegExp("^.*$");
|
|
507
|
+
}
|
|
508
|
+
return new RegExp(`^${path}$`);
|
|
509
|
+
}
|
|
510
|
+
function extractQuery(path) {
|
|
511
|
+
const queryIndex = path.indexOf("?");
|
|
512
|
+
if (queryIndex === -1) {
|
|
513
|
+
return {};
|
|
514
|
+
}
|
|
515
|
+
const search = new URLSearchParams(path.slice(queryIndex + 1));
|
|
516
|
+
return Object.fromEntries(search.entries());
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function assert(condition, message = 'Assertion failed') {
|
|
520
|
+
if (!condition) {
|
|
521
|
+
throw new Error(message)
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const ROUTER_EVENT = "router-event";
|
|
526
|
+
class HashRouter {
|
|
527
|
+
#isInitialized = false;
|
|
528
|
+
#matchers = [];
|
|
529
|
+
#matchedRoute = null;
|
|
530
|
+
#dispatcher = new Dispatcher();
|
|
531
|
+
#subscriptions = new WeakMap();
|
|
532
|
+
#subscriberFns = new Set();
|
|
533
|
+
get matchedRoute() {
|
|
534
|
+
return this.#matchedRoute;
|
|
535
|
+
}
|
|
536
|
+
#params = {};
|
|
537
|
+
get params() {
|
|
538
|
+
return this.#params;
|
|
539
|
+
}
|
|
540
|
+
#query = {};
|
|
541
|
+
get query() {
|
|
542
|
+
return this.#query;
|
|
543
|
+
}
|
|
544
|
+
get #currentRouteHash() {
|
|
545
|
+
const hash = document.location.hash;
|
|
546
|
+
if (hash === "") {
|
|
547
|
+
return "/";
|
|
548
|
+
}
|
|
549
|
+
return hash.slice(1);
|
|
550
|
+
}
|
|
551
|
+
#onPopState = () => this.#matchCurrentRoute();
|
|
552
|
+
constructor(routes = []) {
|
|
553
|
+
assert(Array.isArray(routes), "Routes must be an array");
|
|
554
|
+
this.#matchers = routes.map(makeRouteMatcher);
|
|
555
|
+
}
|
|
556
|
+
async init() {
|
|
557
|
+
if (this.#isInitialized) {
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (document.location.hash === "") {
|
|
561
|
+
window.history.replaceState({}, "", "#/");
|
|
562
|
+
}
|
|
563
|
+
window.addEventListener("popstate", this.#onPopState);
|
|
564
|
+
await this.#matchCurrentRoute();
|
|
565
|
+
this.#isInitialized = true;
|
|
566
|
+
}
|
|
567
|
+
destroy() {
|
|
568
|
+
if (!this.#isInitialized) {
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
window.removeEventListener("popstate", this.#onPopState);
|
|
572
|
+
Array.from(this.#subscriberFns).forEach(this.unsubscribe, this);
|
|
573
|
+
this.#isInitialized = false;
|
|
574
|
+
}
|
|
575
|
+
async navigateTo(path) {
|
|
576
|
+
const matcher = this.#matchers.find((matcher) => matcher.checkMatch(path));
|
|
577
|
+
if (matcher == null) {
|
|
578
|
+
console.warn(`[Router] No route matches path "${path}"`);
|
|
579
|
+
this.#matchedRoute = null;
|
|
580
|
+
this.#params = {};
|
|
581
|
+
this.#query = {};
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
if (matcher.isRedirect) {
|
|
585
|
+
return this.navigateTo(matcher.route.redirect);
|
|
586
|
+
}
|
|
587
|
+
const from = this.#matchedRoute;
|
|
588
|
+
const to = matcher.route;
|
|
589
|
+
const { shouldNavigate, shouldRedirect, redirectPath } =
|
|
590
|
+
await this.#canChangeRoute(from, to);
|
|
591
|
+
if (shouldRedirect) {
|
|
592
|
+
return this.navigateTo(redirectPath);
|
|
593
|
+
}
|
|
594
|
+
if (shouldNavigate) {
|
|
595
|
+
this.#matchedRoute = matcher.route;
|
|
596
|
+
this.#params = matcher.extractParams(path);
|
|
597
|
+
this.#query = matcher.extractQuery(path);
|
|
598
|
+
this.#pushState(path);
|
|
599
|
+
this.#dispatcher.dispatch(ROUTER_EVENT, { from, to, router: this });
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
back() {
|
|
603
|
+
window.history.back();
|
|
604
|
+
}
|
|
605
|
+
forward() {
|
|
606
|
+
window.history.forward();
|
|
607
|
+
}
|
|
608
|
+
subscribe(handler) {
|
|
609
|
+
const unsubscribe = this.#dispatcher.subscribe(ROUTER_EVENT, handler);
|
|
610
|
+
this.#subscriptions.set(handler, unsubscribe);
|
|
611
|
+
this.#subscriberFns.add(handler);
|
|
612
|
+
}
|
|
613
|
+
unsubscribe(handler) {
|
|
614
|
+
const unsubscribe = this.#subscriptions.get(handler);
|
|
615
|
+
if (unsubscribe) {
|
|
616
|
+
unsubscribe();
|
|
617
|
+
this.#subscriptions.delete(handler);
|
|
618
|
+
this.#subscriberFns.delete(handler);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
#pushState(path) {
|
|
622
|
+
window.history.pushState({}, "", `#${path}`);
|
|
623
|
+
}
|
|
624
|
+
#matchCurrentRoute() {
|
|
625
|
+
return this.navigateTo(this.#currentRouteHash);
|
|
626
|
+
}
|
|
627
|
+
async #canChangeRoute(from, to) {
|
|
628
|
+
const guard = to.beforeEnter;
|
|
629
|
+
if (typeof guard !== "function") {
|
|
630
|
+
return {
|
|
631
|
+
shouldRedirect: false,
|
|
632
|
+
shouldNavigate: true,
|
|
633
|
+
redirectPath: null,
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
const result = await guard(from?.path, to?.path);
|
|
637
|
+
if (result === false) {
|
|
638
|
+
return {
|
|
639
|
+
shouldRedirect: false,
|
|
640
|
+
shouldNavigate: false,
|
|
641
|
+
redirectPath: null,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
if (typeof result === "string") {
|
|
645
|
+
return {
|
|
646
|
+
shouldRedirect: true,
|
|
647
|
+
shouldNavigate: false,
|
|
648
|
+
redirectPath: result,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
return {
|
|
652
|
+
shouldRedirect: false,
|
|
653
|
+
shouldNavigate: true,
|
|
654
|
+
redirectPath: null,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
class NoopRouter {
|
|
659
|
+
init() {}
|
|
660
|
+
destroy() {}
|
|
661
|
+
navigateTo() {}
|
|
662
|
+
back() {}
|
|
663
|
+
forward() {}
|
|
664
|
+
subscribe() {}
|
|
665
|
+
unsubscribe() {}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function createBetalApp(RootComponent, props = {}, options = {}) {
|
|
375
669
|
let parentEl = null;
|
|
376
670
|
let isMounted = false;
|
|
377
671
|
let vdom = null;
|
|
672
|
+
const context = {
|
|
673
|
+
router: options.router || new NoopRouter(),
|
|
674
|
+
};
|
|
378
675
|
function reset() {
|
|
379
676
|
parentEl = null;
|
|
380
677
|
isMounted = false;
|
|
@@ -387,7 +684,8 @@ function createApp(RootComponent, props = {}) {
|
|
|
387
684
|
}
|
|
388
685
|
parentEl = _parentEl;
|
|
389
686
|
vdom = h(RootComponent, props);
|
|
390
|
-
mountDOM(vdom, parentEl);
|
|
687
|
+
mountDOM(vdom, parentEl, null, { appContext: context });
|
|
688
|
+
context.router.init();
|
|
391
689
|
isMounted = true;
|
|
392
690
|
},
|
|
393
691
|
unmount() {
|
|
@@ -395,6 +693,7 @@ function createApp(RootComponent, props = {}) {
|
|
|
395
693
|
throw new Error("The application is not mounted");
|
|
396
694
|
}
|
|
397
695
|
destroyDOM(vdom);
|
|
696
|
+
context.router.destroy();
|
|
398
697
|
reset();
|
|
399
698
|
},
|
|
400
699
|
};
|
|
@@ -631,48 +930,53 @@ function patchChildren(oldVdom, newVdom, hostComponent) {
|
|
|
631
930
|
}
|
|
632
931
|
function patchComponent(oldVdom, newVdom) {
|
|
633
932
|
const { component } = oldVdom;
|
|
933
|
+
const { children } = newVdom;
|
|
634
934
|
const { props } = extractPropsAndEvents(newVdom);
|
|
935
|
+
component.setExternalContent(children);
|
|
635
936
|
component.updateProps(props);
|
|
636
937
|
newVdom.component = component;
|
|
637
938
|
newVdom.el = component.firstElement;
|
|
638
939
|
}
|
|
639
940
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
const idx = handlers.indexOf(handler);
|
|
654
|
-
handlers.splice(idx, 1);
|
|
655
|
-
};
|
|
941
|
+
function traverseDFS(
|
|
942
|
+
vdom,
|
|
943
|
+
processNode,
|
|
944
|
+
shouldSkipBranch = () => false,
|
|
945
|
+
parentNode = null,
|
|
946
|
+
index = null
|
|
947
|
+
) {
|
|
948
|
+
if (shouldSkipBranch(vdom)) return;
|
|
949
|
+
processNode(vdom, parentNode, index);
|
|
950
|
+
if (vdom.children) {
|
|
951
|
+
vdom.children.forEach((child, i) =>
|
|
952
|
+
traverseDFS(child, processNode, shouldSkipBranch, vdom, i)
|
|
953
|
+
);
|
|
656
954
|
}
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
};
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function fillSlots(vdom, externalContent = []) {
|
|
958
|
+
function processNode(node, parent, index) {
|
|
959
|
+
insertViewInSlot(node, parent, index, externalContent);
|
|
663
960
|
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
961
|
+
traverseDFS(vdom, processNode, shouldSkipBranch);
|
|
962
|
+
}
|
|
963
|
+
function insertViewInSlot(node, parent, index, externalContent) {
|
|
964
|
+
if (node.type !== DOM_TYPES.SLOT) return;
|
|
965
|
+
const defaultContent = node.children;
|
|
966
|
+
const views = externalContent.length > 0 ? externalContent : defaultContent;
|
|
967
|
+
const hasContent = views.length > 0;
|
|
968
|
+
if (hasContent) {
|
|
969
|
+
parent.children.splice(index, 1, hFragment(views));
|
|
970
|
+
} else {
|
|
971
|
+
parent.children.splice(index, 1);
|
|
672
972
|
}
|
|
673
973
|
}
|
|
974
|
+
function shouldSkipBranch(node) {
|
|
975
|
+
return node.type === DOM_TYPES.COMPONENT;
|
|
976
|
+
}
|
|
674
977
|
|
|
675
|
-
|
|
978
|
+
const emptyFunction = () => {};
|
|
979
|
+
function defineComponent({ render, state, onMounted = emptyFunction, onUnmounted = emptyFunction, onPropsChange = emptyFunction, onStateChange = emptyFunction, ...methods }) {
|
|
676
980
|
class Component {
|
|
677
981
|
#vdom = null;
|
|
678
982
|
#isMounted = false;
|
|
@@ -681,18 +985,29 @@ function defineComponent({ render, state, ...methods }) {
|
|
|
681
985
|
#parentComponent = null;
|
|
682
986
|
#dispatcher = new Dispatcher();
|
|
683
987
|
#subscriptions = [];
|
|
988
|
+
#children = [];
|
|
989
|
+
#appContext = null;
|
|
684
990
|
constructor(props = {}, eventHandlers = {}, parentComponent = null) {
|
|
685
991
|
this.props = props;
|
|
686
992
|
this.state = state ? state(props) : {};
|
|
687
993
|
this.#eventHandlers = eventHandlers;
|
|
688
994
|
this.#parentComponent = parentComponent;
|
|
689
995
|
}
|
|
996
|
+
setExternalContent(children) {
|
|
997
|
+
this.#children = children;
|
|
998
|
+
}
|
|
690
999
|
updateState(newState) {
|
|
691
1000
|
this.state = { ...this.state, ...newState };
|
|
692
1001
|
this.#patch();
|
|
1002
|
+
enqueueJob(() => this.onStateChange());
|
|
693
1003
|
}
|
|
694
1004
|
render() {
|
|
695
|
-
|
|
1005
|
+
const vdom = render.call(this);
|
|
1006
|
+
if (didCreateSlot()) {
|
|
1007
|
+
fillSlots(vdom, this.#children);
|
|
1008
|
+
resetDidCreateSlot();
|
|
1009
|
+
}
|
|
1010
|
+
return vdom;
|
|
696
1011
|
}
|
|
697
1012
|
mount(hostEl, index = null) {
|
|
698
1013
|
if (this.#isMounted) {
|
|
@@ -715,17 +1030,37 @@ function defineComponent({ render, state, ...methods }) {
|
|
|
715
1030
|
this.#hostEl = null;
|
|
716
1031
|
this.#isMounted = false;
|
|
717
1032
|
}
|
|
1033
|
+
onMounted() {
|
|
1034
|
+
return Promise.resolve(onMounted.call(this));
|
|
1035
|
+
}
|
|
1036
|
+
onUnmounted() {
|
|
1037
|
+
return Promise.resolve(onUnmounted.call(this));
|
|
1038
|
+
}
|
|
1039
|
+
onPropsChange(newProps, oldProps) {
|
|
1040
|
+
return Promise.resolve(onPropsChange.call(this, newProps, oldProps));
|
|
1041
|
+
}
|
|
1042
|
+
onStateChange() {
|
|
1043
|
+
return Promise.resolve(onStateChange.call(this));
|
|
1044
|
+
}
|
|
718
1045
|
updateProps(props) {
|
|
719
1046
|
const newProps = { ...this.props, ...props };
|
|
720
1047
|
if (equal(this.props, newProps)) {
|
|
721
1048
|
return;
|
|
722
1049
|
}
|
|
1050
|
+
const oldProps = this.props;
|
|
723
1051
|
this.props = newProps;
|
|
724
1052
|
this.#patch();
|
|
1053
|
+
enqueueJob(() => this.onPropsChange(this.props, oldProps));
|
|
725
1054
|
}
|
|
726
1055
|
emit(eventName, payload) {
|
|
727
1056
|
this.#dispatcher.dispatch(eventName, payload);
|
|
728
1057
|
}
|
|
1058
|
+
setAppContext(appContext) {
|
|
1059
|
+
this.#appContext = appContext;
|
|
1060
|
+
}
|
|
1061
|
+
get appContext() {
|
|
1062
|
+
return this.#appContext;
|
|
1063
|
+
}
|
|
729
1064
|
#patch() {
|
|
730
1065
|
if (!this.#isMounted) {
|
|
731
1066
|
throw new Error("Component is not mounted");
|
|
@@ -780,4 +1115,50 @@ function defineComponent({ render, state, ...methods }) {
|
|
|
780
1115
|
return Component;
|
|
781
1116
|
}
|
|
782
1117
|
|
|
783
|
-
|
|
1118
|
+
const RouterLink = defineComponent({
|
|
1119
|
+
render() {
|
|
1120
|
+
const { to } = this.props;
|
|
1121
|
+
return h(
|
|
1122
|
+
"a",
|
|
1123
|
+
{
|
|
1124
|
+
href: to,
|
|
1125
|
+
on: {
|
|
1126
|
+
click: (e) => {
|
|
1127
|
+
e.preventDefault();
|
|
1128
|
+
this.appContext.router.navigateTo(to);
|
|
1129
|
+
},
|
|
1130
|
+
},
|
|
1131
|
+
},
|
|
1132
|
+
[hSlot()]
|
|
1133
|
+
);
|
|
1134
|
+
},
|
|
1135
|
+
});
|
|
1136
|
+
const RouterOutlet = defineComponent({
|
|
1137
|
+
state() {
|
|
1138
|
+
return {
|
|
1139
|
+
matchedRoute: null,
|
|
1140
|
+
subscription: null,
|
|
1141
|
+
};
|
|
1142
|
+
},
|
|
1143
|
+
onMounted() {
|
|
1144
|
+
const subscription = this.appContext.router.subscribe(({ to }) => {
|
|
1145
|
+
this.handleRouteChange(to);
|
|
1146
|
+
});
|
|
1147
|
+
this.updateState({ subscription });
|
|
1148
|
+
},
|
|
1149
|
+
onUnmounted() {
|
|
1150
|
+
const { subscription } = this.state;
|
|
1151
|
+
this.appContext.router.unsubscribe(subscription);
|
|
1152
|
+
},
|
|
1153
|
+
handleRouteChange(matchedRoute) {
|
|
1154
|
+
this.updateState({ matchedRoute });
|
|
1155
|
+
},
|
|
1156
|
+
render() {
|
|
1157
|
+
const { matchedRoute } = this.state;
|
|
1158
|
+
return h("div", { id: "router-outlet" }, [
|
|
1159
|
+
matchedRoute ? h(matchedRoute.component) : null,
|
|
1160
|
+
]);
|
|
1161
|
+
},
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
export { HashRouter, RouterLink, RouterOutlet, createBetalApp, defineComponent, h, hFragment, hSlot, hString, nextTick };
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "betal-fe",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/betal-fe.js",
|
|
6
6
|
"files": [
|
|
7
|
-
"dist/betal-fe.js"
|
|
7
|
+
"dist/betal-fe.js",
|
|
8
|
+
"README.md"
|
|
8
9
|
],
|
|
9
10
|
"scripts": {
|
|
10
11
|
"build": "rollup -c",
|
|
@@ -14,10 +15,20 @@
|
|
|
14
15
|
"test": "vitest",
|
|
15
16
|
"test:run": "vitest --run"
|
|
16
17
|
},
|
|
17
|
-
"keywords": [
|
|
18
|
+
"keywords": [
|
|
19
|
+
"framework",
|
|
20
|
+
"virtual-dom",
|
|
21
|
+
"vdom",
|
|
22
|
+
"components",
|
|
23
|
+
"reactive",
|
|
24
|
+
"router",
|
|
25
|
+
"slots",
|
|
26
|
+
"spa",
|
|
27
|
+
"frontend"
|
|
28
|
+
],
|
|
18
29
|
"author": "",
|
|
19
|
-
"license": "
|
|
20
|
-
"description": "",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"description": "A lightweight frontend framework with Virtual DOM, reactive components, routing, and slots",
|
|
21
32
|
"devDependencies": {
|
|
22
33
|
"@eslint/js": "^9.38.0",
|
|
23
34
|
"@rollup/plugin-commonjs": "^29.0.0",
|