@rettangoli/fe 0.0.6 โ 0.0.7-rc10
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 +77 -281
- package/package.json +9 -3
- package/src/cli/examples.js +1 -1
- package/src/cli/watch.js +19 -6
- package/src/common.js +1 -1
- package/src/createComponent.js +50 -22
- package/src/parser.js +117 -58
package/README.md
CHANGED
|
@@ -1,324 +1,120 @@
|
|
|
1
|
-
|
|
2
1
|
# Rettangoli Frontend
|
|
3
2
|
|
|
4
|
-
|
|
3
|
+
A modern frontend framework that uses YAML for view definitions, web components for composition, and Immer for state management. Build reactive applications with minimal complexity using just 3 types of files.
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
## Features
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
- **๐๏ธ Three-File Architecture** - `.view.yaml`, `.store.js`, `.handlers.js` files scale from single page to complex applications
|
|
8
|
+
- **๐ YAML Views** - Declarative UI definitions that compile to virtual DOM
|
|
9
|
+
- **๐งฉ Web Components** - Standards-based component architecture
|
|
10
|
+
- **๐ Reactive State** - Immer-powered immutable state management
|
|
11
|
+
- **โก Fast Development** - Hot reload with Vite integration
|
|
12
|
+
- **๐ฏ Template System** - Jempl templating for dynamic content
|
|
13
|
+
- **๐งช Testing Ready** - Pure functions and dependency injection for easy testing
|
|
11
14
|
|
|
12
|
-
|
|
15
|
+
## Quick Start
|
|
13
16
|
|
|
14
17
|
```bash
|
|
15
|
-
|
|
18
|
+
rtgl fe build # Build components
|
|
19
|
+
rtgl fe watch # Start dev server
|
|
16
20
|
```
|
|
17
21
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
```bash
|
|
21
|
-
bun run ../rettangoli-cli/cli.js fe watch
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
Note: rettangoli-vt is not setup for this project yet. We just use static files under `viz/static` folder.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
## Introduction
|
|
22
|
+
## Documentation
|
|
29
23
|
|
|
30
|
-
|
|
24
|
+
- **[Developer Quickstart](./docs/overview.md)** - Complete introduction and examples
|
|
25
|
+
- **[View System](./docs/view.md)** - Complete YAML syntax
|
|
26
|
+
- **[Store Management](./docs/store.md)** - State patterns
|
|
27
|
+
- **[Event Handlers](./docs/handlers.md)** - Event handling
|
|
31
28
|
|
|
32
|
-
|
|
29
|
+
## Architecture
|
|
33
30
|
|
|
34
|
-
|
|
35
|
-
* web components for components
|
|
31
|
+
### Technology Stack
|
|
36
32
|
|
|
37
|
-
Runtime
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
33
|
+
**Runtime:**
|
|
34
|
+
- [Snabbdom](https://github.com/snabbdom/snabbdom) - Virtual DOM
|
|
35
|
+
- [Immer](https://github.com/immerjs/immer) - Immutable state management
|
|
36
|
+
- [Jempl](https://github.com/yuusoft-org/jempl) - Template engine
|
|
37
|
+
- [RxJS](https://github.com/ReactiveX/rxjs) - Reactive programming
|
|
42
38
|
|
|
43
|
-
Build & Development
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
**Build & Development:**
|
|
40
|
+
- [ESBuild](https://esbuild.github.io/) - Fast bundling
|
|
41
|
+
- [Vite](https://vite.dev/) - Development server with hot reload
|
|
46
42
|
|
|
47
|
-
|
|
43
|
+
**Browser Native:**
|
|
44
|
+
- Web Components - Component encapsulation
|
|
48
45
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
* The yaml will be converted into json at build time. The json will then be consumed by snabbdom to be transformed into html through a virtual dom.
|
|
52
|
-
|
|
53
|
-
### Yaml to write html
|
|
54
|
-
|
|
55
|
-
Standard html can be totally written in yaml.
|
|
56
|
-
|
|
57
|
-
All child element are arrays. Except for things like actual content of text
|
|
58
|
-
|
|
59
|
-
Use `#` and `.` selectors to represent `id` and `class`.
|
|
60
|
-
|
|
61
|
-
`div#myid.class1.class2 custorm-attribute=abcd`
|
|
62
|
-
|
|
63
|
-
will become
|
|
64
|
-
|
|
65
|
-
`<div id="myid" class="class1 class2" custom-attribute="abcd"></div>`
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
### Templating using json-e
|
|
46
|
+
## Development
|
|
69
47
|
|
|
70
|
-
|
|
48
|
+
### Prerequisites
|
|
71
49
|
|
|
50
|
+
- Node.js 18+ or Bun
|
|
51
|
+
- A `rettangoli.config.yaml` file in your project root
|
|
72
52
|
|
|
73
|
-
|
|
53
|
+
### Setup
|
|
74
54
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
- $map: { $eval: projects }
|
|
79
|
-
each(v,k):
|
|
80
|
-
- rtgl-view#project-${v.id} h=64 w=f bw=xs p=m cur=p:
|
|
81
|
-
- rtgl-text s=lg: "${v.name}"
|
|
82
|
-
- rtgl-text s=sm: "${v.description}"
|
|
55
|
+
1. **Install dependencies**:
|
|
56
|
+
```bash
|
|
57
|
+
bun install
|
|
83
58
|
```
|
|
84
59
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
- rtgl-view d=h w=f h=f:
|
|
90
|
-
- $switch:
|
|
91
|
-
'showSidebar':
|
|
92
|
-
- sidebar-component: []
|
|
93
|
-
- rtgl-view w=f h=f:
|
|
94
|
-
- $switch:
|
|
95
|
-
'currentRoute== "/projects"':
|
|
96
|
-
- projects-component: []
|
|
97
|
-
'currentRoute== "/profile"':
|
|
60
|
+
2. **Create project structure**:
|
|
61
|
+
```bash
|
|
62
|
+
# Scaffold a new component
|
|
63
|
+
node ../rettangoli-cli/cli.js fe scaffold --category components --name MyButton
|
|
98
64
|
```
|
|
99
65
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
### Define a elementName
|
|
66
|
+
3. **Start development**:
|
|
67
|
+
```bash
|
|
68
|
+
# Build once
|
|
69
|
+
node ../rettangoli-cli/cli.js fe build
|
|
105
70
|
|
|
106
|
-
|
|
107
|
-
|
|
71
|
+
# Watch for changes (recommended)
|
|
72
|
+
node ../rettangoli-cli/cli.js fe watch
|
|
108
73
|
```
|
|
109
74
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
The component can later be used as `<custom-projects></custom-projects>`.
|
|
113
|
-
|
|
114
|
-
### Styles
|
|
75
|
+
### Project Structure
|
|
115
76
|
|
|
116
|
-
Styles can also be completely written in yaml.
|
|
117
|
-
|
|
118
|
-
```yaml
|
|
119
|
-
styles:
|
|
120
|
-
'#title':
|
|
121
|
-
font-size: 24px
|
|
122
|
-
'@media (min-width: 768px)':
|
|
123
|
-
'#title':
|
|
124
|
-
font-size: 32px
|
|
125
77
|
```
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
eventListeners:
|
|
139
|
-
click:
|
|
140
|
-
handler: handleProjectsClick
|
|
141
|
-
|
|
142
|
-
template:
|
|
143
|
-
- rtgl-button#createButton: Create Project
|
|
144
|
-
- rtgl-view w=f g=m:
|
|
145
|
-
- $map: { $eval: projects }
|
|
146
|
-
each(v,k):
|
|
147
|
-
- rtgl-view#project-${v.id} h=64 w=f bw=xs p=m cur=p:
|
|
148
|
-
- rtgl-text s=lg: "${v.name}"
|
|
149
|
-
- rtgl-text s=sm: "${v.description}"
|
|
78
|
+
src/
|
|
79
|
+
โโโ cli/
|
|
80
|
+
โ โโโ build.js # Build component bundles
|
|
81
|
+
โ โโโ watch.js # Development server with hot reload
|
|
82
|
+
โ โโโ scaffold.js # Component scaffolding
|
|
83
|
+
โ โโโ examples.js # Generate examples for testing
|
|
84
|
+
โ โโโ blank/ # Component templates
|
|
85
|
+
โโโ createComponent.js # Component factory
|
|
86
|
+
โโโ createWebPatch.js # Virtual DOM patching
|
|
87
|
+
โโโ parser.js # YAML to JSON converter
|
|
88
|
+
โโโ common.js # Shared utilities
|
|
89
|
+
โโโ index.js # Main exports
|
|
150
90
|
```
|
|
151
91
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
### Defining data schema
|
|
155
|
-
|
|
156
|
-
Component have a few types of data that can be defined using a JSON schema:
|
|
157
|
-
|
|
158
|
-
* `viewDataSchema` - The data that will used for the template.
|
|
159
|
-
* `propsSchema` - The data that will be passed to the component via javascript, those can be objects.
|
|
160
|
-
* `attrsSchema` - The data that will be passed to the component via html attributes, this is raw strings.
|
|
92
|
+
## Configuration
|
|
161
93
|
|
|
94
|
+
Create a `rettangoli.config.yaml` file in your project root:
|
|
162
95
|
|
|
163
96
|
```yaml
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
default: Create Project
|
|
173
|
-
projects:
|
|
174
|
-
type: array
|
|
175
|
-
items:
|
|
176
|
-
type: object
|
|
177
|
-
properties:
|
|
178
|
-
id:
|
|
179
|
-
type: string
|
|
180
|
-
name:
|
|
181
|
-
type: string
|
|
182
|
-
default: Project 1
|
|
183
|
-
description:
|
|
184
|
-
type: string
|
|
185
|
-
default: Project 1 description
|
|
186
|
-
propsSchema:
|
|
187
|
-
type: object
|
|
188
|
-
properties: {}
|
|
97
|
+
fe:
|
|
98
|
+
dirs:
|
|
99
|
+
- "./src/components"
|
|
100
|
+
- "./src/pages"
|
|
101
|
+
setup: "setup.js"
|
|
102
|
+
outfile: "./dist/bundle.js"
|
|
103
|
+
examples:
|
|
104
|
+
outputDir: "./vt/specs/examples"
|
|
189
105
|
```
|
|
190
106
|
|
|
191
|
-
|
|
192
|
-
## State Store
|
|
193
|
-
|
|
194
|
-
* Define `initial state`
|
|
195
|
-
* `toViewData` will take current `state`, `props` and `attrs` and return the `viewData` to be used by the view template
|
|
196
|
-
* Any exported function that starts with `select` will beceme selectors and are used by handlers to access state data
|
|
197
|
-
* `actions` are all other exported functions that are used to mutate the state.
|
|
198
|
-
|
|
199
|
-
```js
|
|
200
|
-
export const INITIAL_STATE = Object.freeze({
|
|
201
|
-
title: "Projects",
|
|
202
|
-
createButtonText: "Create Project",
|
|
203
|
-
projects: [
|
|
204
|
-
{
|
|
205
|
-
id: "1",
|
|
206
|
-
name: "Project 1",
|
|
207
|
-
description: "Project 1 description",
|
|
208
|
-
},
|
|
209
|
-
{
|
|
210
|
-
id: '2',
|
|
211
|
-
name: 'Project 2',
|
|
212
|
-
description: 'Project 2 description'
|
|
213
|
-
}
|
|
214
|
-
],
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
export const toViewData = ({ state, props }, payload) => {
|
|
218
|
-
return state;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
export const selectProjects = (state, props, payload) => {
|
|
222
|
-
return state.projects;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
export const setProjects = (state, payload) => {
|
|
226
|
-
|
|
227
|
-
}
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
Note that this is just a dump store, it is not reactive. Components will need to call `deps.render()` from handlers to re-render the component.
|
|
231
|
-
|
|
232
|
-
## Handlers
|
|
233
|
-
|
|
234
|
-
`handleOnMount` is a special handler that is called when the component is mounted. It returns a promise that resolves when the component is mounted.
|
|
235
|
-
|
|
236
|
-
All other exported functions will automatically become handlers and can be used in the view layers's `eventListeners`
|
|
237
|
-
|
|
238
|
-
A special object called `deps` is injected into all handlers. It has the following properties:
|
|
239
|
-
|
|
240
|
-
* `deps.render()` will re-render the component. We call this function each time we have chaged state and want to re-render the component.
|
|
241
|
-
* `deps.store` is the store instance. Use selectors to select state and actions to mutate state.
|
|
242
|
-
* `deps.transformedHandlers` can be used to call other handlers.
|
|
243
|
-
* `deps.attrs` is the html attributes that are passed to the component.
|
|
244
|
-
* `deps.props` is the javascript properties that are passed to the component.
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
```js
|
|
248
|
-
export const handleOnMount = (deps) => {
|
|
249
|
-
() => {
|
|
250
|
-
// unsubscribe
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
export const handleCreateButtonClick = async (e, deps) => {
|
|
255
|
-
const { store, deps, render } = deps;
|
|
256
|
-
const formIsVisible = store.selectFormIsVisible();
|
|
257
|
-
|
|
258
|
-
if (!formIsVisible) {
|
|
259
|
-
store.setFormIsVisible(true);
|
|
260
|
-
}
|
|
261
|
-
deps.render();
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
export const handleProjectsClick = (e, deps) => {
|
|
265
|
-
const id = e.target.id
|
|
266
|
-
console.log('handleProjectsClick', id);
|
|
267
|
-
}
|
|
268
|
-
```
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
* `deps.dispatchEvent` can be used to dispatch custom dom events.
|
|
273
|
-
|
|
274
|
-
```js
|
|
275
|
-
export const handleProjectsClick = (e, deps) => {
|
|
276
|
-
deps.dispatchEvent(new CustomEvent('project-clicked', {
|
|
277
|
-
projectId: '1',
|
|
278
|
-
}));
|
|
279
|
-
}
|
|
280
|
-
```
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
### Adding additional dependencies
|
|
284
|
-
|
|
285
|
-
This is a simple yet powerful way to do dependency injection. Those are all global singleton dependencies. Technically anything can be injected and be made accessible to all components.
|
|
286
|
-
|
|
287
|
-
```js
|
|
288
|
-
const componentDependencies = {
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const pageDependencies = {
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
export const deps = {
|
|
295
|
-
components: componentDependencies,
|
|
296
|
-
pages: pageDependencies,
|
|
297
|
-
}
|
|
298
|
-
```
|
|
299
|
-
|
|
300
|
-
|
|
301
107
|
## Testing
|
|
302
108
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
## View
|
|
307
|
-
|
|
308
|
-
Visual testing `rettangoli-vt`
|
|
109
|
+
### View Components
|
|
309
110
|
|
|
111
|
+
Use visual testing with `rtgl vt`:
|
|
310
112
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
Example
|
|
316
|
-
|
|
317
|
-
```yaml
|
|
318
|
-
...
|
|
113
|
+
```bash
|
|
114
|
+
rtgl vt generate
|
|
115
|
+
rtgl vt report
|
|
319
116
|
```
|
|
320
117
|
|
|
321
|
-
##
|
|
118
|
+
## Examples
|
|
322
119
|
|
|
323
|
-
|
|
324
|
-
They are not always pure per se due to calling of dependencies.
|
|
120
|
+
For a complete working example, see the todos app in `examples/example1/`.
|
package/package.json
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rettangoli/fe",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.7-rc10",
|
|
4
4
|
"description": "Frontend framework for building reactive web components",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
|
-
"keywords": [
|
|
7
|
+
"keywords": [
|
|
8
|
+
"frontend",
|
|
9
|
+
"reactive",
|
|
10
|
+
"components",
|
|
11
|
+
"web",
|
|
12
|
+
"framework"
|
|
13
|
+
],
|
|
8
14
|
"files": [
|
|
9
15
|
"src",
|
|
10
16
|
"README.md",
|
|
@@ -23,7 +29,7 @@
|
|
|
23
29
|
"dependencies": {
|
|
24
30
|
"esbuild": "^0.25.5",
|
|
25
31
|
"immer": "^10.1.1",
|
|
26
|
-
"jempl": "
|
|
32
|
+
"jempl": "0.1.2-rc2",
|
|
27
33
|
"js-yaml": "^4.1.0",
|
|
28
34
|
"rxjs": "^7.8.2",
|
|
29
35
|
"snabbdom": "^3.6.2",
|
package/src/cli/examples.js
CHANGED
|
@@ -133,7 +133,7 @@ const examples = (options = {}) => {
|
|
|
133
133
|
for (const [index, example] of examples.entries()) {
|
|
134
134
|
const { name, viewData } = example;
|
|
135
135
|
const ast = parse(template);
|
|
136
|
-
const renderedView = flattenArrays(render(
|
|
136
|
+
const renderedView = flattenArrays(render(ast, viewData, {}));
|
|
137
137
|
const html = yamlToHtml(renderedView);
|
|
138
138
|
output.push({
|
|
139
139
|
category,
|
package/src/cli/watch.js
CHANGED
|
@@ -8,7 +8,7 @@ import buildRettangoliFrontend from './build.js';
|
|
|
8
8
|
import { extractCategoryAndComponent } from '../common.js';
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
const setupWatcher = (directory) => {
|
|
11
|
+
const setupWatcher = (directory, options) => {
|
|
12
12
|
watch(
|
|
13
13
|
directory,
|
|
14
14
|
{ recursive: true },
|
|
@@ -21,7 +21,7 @@ const setupWatcher = (directory) => {
|
|
|
21
21
|
const { category, component } = extractCategoryAndComponent(filename);
|
|
22
22
|
await writeViewFile(view, category, component);
|
|
23
23
|
}
|
|
24
|
-
await buildRettangoliFrontend(
|
|
24
|
+
await buildRettangoliFrontend(options);
|
|
25
25
|
} catch (error) {
|
|
26
26
|
console.error(`Error processing ${filename}:`, error);
|
|
27
27
|
// Keep the watcher running
|
|
@@ -32,7 +32,13 @@ const setupWatcher = (directory) => {
|
|
|
32
32
|
};
|
|
33
33
|
|
|
34
34
|
async function startViteServer(options) {
|
|
35
|
-
const { port = 3001,
|
|
35
|
+
const { port = 3001, outfile = "./vt/static/main.js" } = options;
|
|
36
|
+
|
|
37
|
+
// Extract the directory from outfile path
|
|
38
|
+
const outDir = path.dirname(outfile);
|
|
39
|
+
// Go up one level from the JS file directory to serve the site root
|
|
40
|
+
const root = path.dirname(outDir);
|
|
41
|
+
console.log('watch root dir:', root)
|
|
36
42
|
try {
|
|
37
43
|
const server = await createServer({
|
|
38
44
|
// any valid user config options, plus `mode` and `configFile`
|
|
@@ -40,6 +46,8 @@ async function startViteServer(options) {
|
|
|
40
46
|
root,
|
|
41
47
|
server: {
|
|
42
48
|
port,
|
|
49
|
+
host: '0.0.0.0',
|
|
50
|
+
allowedHosts: true
|
|
43
51
|
},
|
|
44
52
|
});
|
|
45
53
|
await server.listen();
|
|
@@ -53,14 +61,19 @@ async function startViteServer(options) {
|
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
|
|
56
|
-
const startWatching = (options) => {
|
|
64
|
+
const startWatching = async (options) => {
|
|
57
65
|
const { dirs = ['src'], port = 3001 } = options;
|
|
58
66
|
|
|
67
|
+
// Do initial build with all directories
|
|
68
|
+
console.log('Starting initial build...');
|
|
69
|
+
await buildRettangoliFrontend(options);
|
|
70
|
+
console.log('Initial build complete');
|
|
71
|
+
|
|
59
72
|
dirs.forEach(dir => {
|
|
60
|
-
setupWatcher(dir);
|
|
73
|
+
setupWatcher(dir, options);
|
|
61
74
|
});
|
|
62
75
|
|
|
63
|
-
startViteServer({ port });
|
|
76
|
+
startViteServer({ port, outfile: options.outfile });
|
|
64
77
|
}
|
|
65
78
|
|
|
66
79
|
export default startWatching;
|
package/src/common.js
CHANGED
|
@@ -8,7 +8,7 @@ import { Subject } from "rxjs";
|
|
|
8
8
|
* const subject = new CustomSubject();
|
|
9
9
|
*
|
|
10
10
|
* const subscription = subject.subscribe(({ action, payload }) => {
|
|
11
|
-
*
|
|
11
|
+
* // handle action and payload
|
|
12
12
|
* });
|
|
13
13
|
*
|
|
14
14
|
* subject.dispatch("action", { payload: "payload" });
|
package/src/createComponent.js
CHANGED
|
@@ -4,7 +4,7 @@ import { parseView } from "./parser.js";
|
|
|
4
4
|
/**
|
|
5
5
|
* covert this format of json into raw css strings
|
|
6
6
|
* notice if propoperty starts with \@, it will need to nest it
|
|
7
|
-
*
|
|
7
|
+
*
|
|
8
8
|
':host':
|
|
9
9
|
display: contents
|
|
10
10
|
'button':
|
|
@@ -26,8 +26,8 @@ import { parseView } from "./parser.js";
|
|
|
26
26
|
'@media (min-width: 768px)':
|
|
27
27
|
'button':
|
|
28
28
|
height: 40px
|
|
29
|
-
* @param {*} styleObject
|
|
30
|
-
* @returns
|
|
29
|
+
* @param {*} styleObject
|
|
30
|
+
* @returns
|
|
31
31
|
*/
|
|
32
32
|
const yamlToCss = (elementName, styleObject) => {
|
|
33
33
|
if (!styleObject || typeof styleObject !== "object") {
|
|
@@ -104,7 +104,9 @@ function createAttrsProxy(source) {
|
|
|
104
104
|
{
|
|
105
105
|
get(_, prop) {
|
|
106
106
|
if (typeof prop === "string") {
|
|
107
|
-
|
|
107
|
+
const value = source.getAttribute(prop);
|
|
108
|
+
// Return true for boolean attributes (empty string values)
|
|
109
|
+
return value === "" ? true : value;
|
|
108
110
|
}
|
|
109
111
|
return undefined;
|
|
110
112
|
},
|
|
@@ -313,15 +315,24 @@ class BaseComponent extends HTMLElement {
|
|
|
313
315
|
};
|
|
314
316
|
});
|
|
315
317
|
|
|
316
|
-
if (this.handlers?.
|
|
317
|
-
this.
|
|
318
|
-
}
|
|
318
|
+
if (this.handlers?.handleBeforeMount) {
|
|
319
|
+
this._unmountCallback = this.handlers?.handleBeforeMount(deps);
|
|
319
320
|
|
|
320
|
-
|
|
321
|
-
this._unmountCallback
|
|
321
|
+
// Validate that handleBeforeMount doesn't return a Promise
|
|
322
|
+
if (this._unmountCallback && typeof this._unmountCallback.then === 'function') {
|
|
323
|
+
throw new Error('handleBeforeMount must be synchronous and cannot return a Promise.');
|
|
324
|
+
}
|
|
322
325
|
}
|
|
323
326
|
|
|
324
327
|
this.render();
|
|
328
|
+
|
|
329
|
+
if (this.handlers?.handleAfterMount) {
|
|
330
|
+
this.handlers?.handleAfterMount(deps);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (this.handlers?.subscriptions) {
|
|
334
|
+
this.unsubscribeAll = subscribeAll(this.handlers.subscriptions(deps));
|
|
335
|
+
}
|
|
325
336
|
}
|
|
326
337
|
|
|
327
338
|
disconnectedCallback() {
|
|
@@ -335,9 +346,23 @@ class BaseComponent extends HTMLElement {
|
|
|
335
346
|
|
|
336
347
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
337
348
|
if (oldValue !== newValue && this.render) {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
349
|
+
// Call handleOnUpdate if it exists
|
|
350
|
+
if (this.handlers?.handleOnUpdate) {
|
|
351
|
+
const changes = { [name]: { oldValue, newValue } };
|
|
352
|
+
const deps = {
|
|
353
|
+
...this.deps,
|
|
354
|
+
refIds: this.refIds,
|
|
355
|
+
getRefIds: () => this.refIds,
|
|
356
|
+
dispatchEvent: this.dispatchEvent.bind(this),
|
|
357
|
+
store: this.store,
|
|
358
|
+
render: this.render.bind(this),
|
|
359
|
+
};
|
|
360
|
+
this.handlers.handleOnUpdate(changes, deps);
|
|
361
|
+
} else {
|
|
362
|
+
requestAnimationFrame(() => {
|
|
363
|
+
this.render();
|
|
364
|
+
});
|
|
365
|
+
}
|
|
341
366
|
}
|
|
342
367
|
}
|
|
343
368
|
|
|
@@ -353,7 +378,15 @@ class BaseComponent extends HTMLElement {
|
|
|
353
378
|
}
|
|
354
379
|
|
|
355
380
|
try {
|
|
356
|
-
|
|
381
|
+
const deps = {
|
|
382
|
+
...this.deps,
|
|
383
|
+
refIds: this.refIds,
|
|
384
|
+
getRefIds: () => this.refIds,
|
|
385
|
+
dispatchEvent: this.dispatchEvent.bind(this),
|
|
386
|
+
store: this.store,
|
|
387
|
+
render: this.render.bind(this),
|
|
388
|
+
};
|
|
389
|
+
|
|
357
390
|
const vDom = parseView({
|
|
358
391
|
h: this.h,
|
|
359
392
|
template: this.template,
|
|
@@ -361,9 +394,6 @@ class BaseComponent extends HTMLElement {
|
|
|
361
394
|
refs: this.refs,
|
|
362
395
|
handlers: this.transformedHandlers,
|
|
363
396
|
});
|
|
364
|
-
|
|
365
|
-
// const parseTime = performance.now() - parseStart;
|
|
366
|
-
// console.log(`parseView took ${parseTime.toFixed(2)}ms`);
|
|
367
397
|
// parse through vDom and recursively find all elements with id
|
|
368
398
|
const ids = {};
|
|
369
399
|
const findIds = (vDom) => {
|
|
@@ -377,15 +407,11 @@ class BaseComponent extends HTMLElement {
|
|
|
377
407
|
findIds(vDom);
|
|
378
408
|
this.refIds = ids;
|
|
379
409
|
|
|
380
|
-
// const patchStart = performance.now();
|
|
381
410
|
if (!this._oldVNode) {
|
|
382
411
|
this._oldVNode = this.patch(this.renderTarget, vDom);
|
|
383
412
|
} else {
|
|
384
413
|
this._oldVNode = this.patch(this._oldVNode, vDom);
|
|
385
414
|
}
|
|
386
|
-
|
|
387
|
-
// const patchTime = performance.now() - patchStart;
|
|
388
|
-
// console.log(`patch took ${patchTime.toFixed(2)}ms`);
|
|
389
415
|
} catch (error) {
|
|
390
416
|
console.error("Error during patching:", error);
|
|
391
417
|
}
|
|
@@ -430,7 +456,7 @@ const bindStore = (store, props, attrs) => {
|
|
|
430
456
|
};
|
|
431
457
|
|
|
432
458
|
const createComponent = ({ handlers, view, store, patch, h }, deps) => {
|
|
433
|
-
const { elementName, propsSchema, template, refs, styles } = view;
|
|
459
|
+
const { elementName, propsSchema, attrsSchema, template, refs, styles } = view;
|
|
434
460
|
|
|
435
461
|
if (!patch) {
|
|
436
462
|
throw new Error("Patch is not defined");
|
|
@@ -447,7 +473,9 @@ const createComponent = ({ handlers, view, store, patch, h }, deps) => {
|
|
|
447
473
|
class MyComponent extends BaseComponent {
|
|
448
474
|
|
|
449
475
|
static get observedAttributes() {
|
|
450
|
-
|
|
476
|
+
const baseAttrs = ["key"];
|
|
477
|
+
const attrKeys = attrsSchema?.properties ? Object.keys(attrsSchema.properties) : [];
|
|
478
|
+
return [...baseAttrs, ...attrKeys];
|
|
451
479
|
}
|
|
452
480
|
|
|
453
481
|
constructor() {
|
package/src/parser.js
CHANGED
|
@@ -4,16 +4,16 @@ import { flattenArrays } from './common.js';
|
|
|
4
4
|
|
|
5
5
|
const lodashGet = (obj, path) => {
|
|
6
6
|
if (!path) return obj;
|
|
7
|
-
|
|
7
|
+
|
|
8
8
|
// Parse path to handle both dot notation and bracket notation
|
|
9
9
|
const parts = [];
|
|
10
10
|
let current = '';
|
|
11
11
|
let inBrackets = false;
|
|
12
12
|
let quoteChar = null;
|
|
13
|
-
|
|
13
|
+
|
|
14
14
|
for (let i = 0; i < path.length; i++) {
|
|
15
15
|
const char = path[i];
|
|
16
|
-
|
|
16
|
+
|
|
17
17
|
if (!inBrackets && char === '.') {
|
|
18
18
|
if (current) {
|
|
19
19
|
parts.push(current);
|
|
@@ -28,14 +28,14 @@ const lodashGet = (obj, path) => {
|
|
|
28
28
|
} else if (inBrackets && char === ']') {
|
|
29
29
|
if (current) {
|
|
30
30
|
// Remove quotes if present and add the key
|
|
31
|
-
if ((current.startsWith('"') && current.endsWith('"')) ||
|
|
32
|
-
|
|
31
|
+
if ((current.startsWith('"') && current.endsWith('"')) ||
|
|
32
|
+
(current.startsWith("'") && current.endsWith("'"))) {
|
|
33
33
|
parts.push(current.slice(1, -1));
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
} else {
|
|
35
|
+
// Numeric index or unquoted string
|
|
36
|
+
const numValue = Number(current);
|
|
37
|
+
parts.push(isNaN(numValue) ? current : numValue);
|
|
38
|
+
}
|
|
39
39
|
current = '';
|
|
40
40
|
}
|
|
41
41
|
inBrackets = false;
|
|
@@ -51,24 +51,17 @@ const lodashGet = (obj, path) => {
|
|
|
51
51
|
current += char;
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
-
|
|
54
|
+
|
|
55
55
|
if (current) {
|
|
56
56
|
parts.push(current);
|
|
57
57
|
}
|
|
58
|
-
|
|
58
|
+
|
|
59
59
|
return parts.reduce((acc, part) => acc && acc[part], obj);
|
|
60
60
|
};
|
|
61
61
|
|
|
62
62
|
export const parseView = ({ h, template, viewData, refs, handlers }) => {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
ast: template,
|
|
66
|
-
data: viewData,
|
|
67
|
-
});
|
|
68
|
-
// const endTime = performance.now();
|
|
69
|
-
// const executionTime = endTime - startTime;
|
|
70
|
-
// console.log(`jemplRender execution time: ${executionTime.toFixed(2)}ms`);
|
|
71
|
-
|
|
63
|
+
const result = jemplRender(template, viewData, {});
|
|
64
|
+
|
|
72
65
|
// Flatten the array carefully to maintain structure
|
|
73
66
|
const flattenedResult = flattenArrays(result);
|
|
74
67
|
|
|
@@ -98,7 +91,7 @@ export const createVirtualDom = ({
|
|
|
98
91
|
items,
|
|
99
92
|
refs = {},
|
|
100
93
|
handlers = {},
|
|
101
|
-
viewData = {}
|
|
94
|
+
viewData = {}
|
|
102
95
|
}) => {
|
|
103
96
|
if (!Array.isArray(items)) {
|
|
104
97
|
console.error("Input to createVirtualDom must be an array.");
|
|
@@ -120,7 +113,7 @@ export const createVirtualDom = ({
|
|
|
120
113
|
|
|
121
114
|
const entries = Object.entries(item);
|
|
122
115
|
if (entries.length === 0) {
|
|
123
|
-
|
|
116
|
+
// skipping empty object item
|
|
124
117
|
return null;
|
|
125
118
|
}
|
|
126
119
|
|
|
@@ -167,17 +160,65 @@ export const createVirtualDom = ({
|
|
|
167
160
|
const attrs = {}; // Ensure attrs is always an object
|
|
168
161
|
const props = {};
|
|
169
162
|
if (attrsString) {
|
|
170
|
-
|
|
163
|
+
// First, handle attributes with values
|
|
164
|
+
const attrRegex = /(\S+?)=(?:\"([^\"]*)\"|\'([^\']*)\'|([^\s]+))/g;
|
|
171
165
|
let match;
|
|
166
|
+
const processedAttrs = new Set();
|
|
167
|
+
|
|
172
168
|
while ((match = attrRegex.exec(attrsString)) !== null) {
|
|
169
|
+
processedAttrs.add(match[1]);
|
|
173
170
|
if (match[1].startsWith(".")) {
|
|
174
171
|
const propName = match[1].substring(1);
|
|
175
172
|
const valuePathName = match[4];
|
|
176
173
|
props[propName] = lodashGet(viewData, valuePathName);
|
|
174
|
+
} else if (match[1].startsWith("?")) {
|
|
175
|
+
// Handle conditional boolean attributes
|
|
176
|
+
const attrName = match[1].substring(1);
|
|
177
|
+
const attrValue = match[2] || match[3] || match[4];
|
|
178
|
+
|
|
179
|
+
// Convert string values to boolean
|
|
180
|
+
let evalValue;
|
|
181
|
+
if (attrValue === "true") {
|
|
182
|
+
evalValue = true;
|
|
183
|
+
} else if (attrValue === "false") {
|
|
184
|
+
evalValue = false;
|
|
185
|
+
} else {
|
|
186
|
+
// Try to get from viewData if it's not a literal boolean
|
|
187
|
+
evalValue = lodashGet(viewData, attrValue);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Only add attribute if value is truthy
|
|
191
|
+
if (evalValue) {
|
|
192
|
+
attrs[attrName] = "";
|
|
193
|
+
}
|
|
177
194
|
} else {
|
|
178
195
|
attrs[match[1]] = match[2] || match[3] || match[4];
|
|
179
196
|
}
|
|
180
197
|
}
|
|
198
|
+
|
|
199
|
+
// Then, handle boolean attributes without values
|
|
200
|
+
// Remove all processed attribute-value pairs from the string first
|
|
201
|
+
let remainingAttrsString = attrsString;
|
|
202
|
+
const processedMatches = [];
|
|
203
|
+
let tempMatch;
|
|
204
|
+
const tempAttrRegex = /(\S+?)=(?:\"([^\"]*)\"|\'([^\']*)\'|([^\s]+))/g;
|
|
205
|
+
while ((tempMatch = tempAttrRegex.exec(attrsString)) !== null) {
|
|
206
|
+
processedMatches.push(tempMatch[0]);
|
|
207
|
+
}
|
|
208
|
+
// Remove all matched attribute=value pairs
|
|
209
|
+
processedMatches.forEach(match => {
|
|
210
|
+
remainingAttrsString = remainingAttrsString.replace(match, ' ');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const booleanAttrRegex = /\b(\S+?)(?=\s|$)/g;
|
|
214
|
+
let boolMatch;
|
|
215
|
+
while ((boolMatch = booleanAttrRegex.exec(remainingAttrsString)) !== null) {
|
|
216
|
+
const attrName = boolMatch[1];
|
|
217
|
+
// Skip if already processed or starts with . (prop) or contains =
|
|
218
|
+
if (!processedAttrs.has(attrName) && !attrName.startsWith(".") && !attrName.includes("=")) {
|
|
219
|
+
attrs[attrName] = "";
|
|
220
|
+
}
|
|
221
|
+
}
|
|
181
222
|
}
|
|
182
223
|
|
|
183
224
|
// 2. Handle ID from selector string (e.g., tag#id)
|
|
@@ -335,42 +376,60 @@ export const createVirtualDom = ({
|
|
|
335
376
|
}
|
|
336
377
|
if (Object.keys(props).length > 0) {
|
|
337
378
|
snabbdomData.props = props;
|
|
379
|
+
}
|
|
338
380
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
381
|
+
// For web components, add a hook to detect prop and attr changes
|
|
382
|
+
if (isWebComponent) {
|
|
383
|
+
snabbdomData.hook = {
|
|
384
|
+
update: (oldVnode, vnode) => {
|
|
385
|
+
const oldProps = oldVnode.data?.props || {};
|
|
386
|
+
const newProps = vnode.data?.props || {};
|
|
387
|
+
const oldAttrs = oldVnode.data?.attrs || {};
|
|
388
|
+
const newAttrs = vnode.data?.attrs || {};
|
|
389
|
+
|
|
390
|
+
// Check if props have changed
|
|
391
|
+
const propsChanged =
|
|
392
|
+
JSON.stringify(oldProps) !== JSON.stringify(newProps);
|
|
393
|
+
|
|
394
|
+
// Check if attrs have changed
|
|
395
|
+
const attrsChanged =
|
|
396
|
+
JSON.stringify(oldAttrs) !== JSON.stringify(newAttrs);
|
|
397
|
+
|
|
398
|
+
if (propsChanged || attrsChanged) {
|
|
399
|
+
// Set isDirty attribute and trigger re-render
|
|
400
|
+
const element = vnode.elm;
|
|
401
|
+
if (
|
|
402
|
+
element &&
|
|
403
|
+
element.render &&
|
|
404
|
+
typeof element.render === "function"
|
|
405
|
+
) {
|
|
406
|
+
element.setAttribute("isDirty", "true");
|
|
407
|
+
requestAnimationFrame(() => {
|
|
408
|
+
element.render();
|
|
409
|
+
element.removeAttribute("isDirty");
|
|
410
|
+
// Call the specific component's handleOnUpdate instead of the parent's onUpdate
|
|
411
|
+
if (element.handlers && element.handlers.handleOnUpdate) {
|
|
412
|
+
const deps = {
|
|
413
|
+
...(element.deps || {}),
|
|
414
|
+
store: element.store,
|
|
415
|
+
render: element.render.bind(element),
|
|
416
|
+
handlers: element.handlers,
|
|
417
|
+
dispatchEvent: element.dispatchEvent.bind(element),
|
|
418
|
+
refIds: element.refIds || {},
|
|
419
|
+
getRefIds: () => element.refIds || {},
|
|
420
|
+
};
|
|
421
|
+
element.handlers.handleOnUpdate({
|
|
422
|
+
oldProps,
|
|
423
|
+
newProps,
|
|
424
|
+
oldAttrs,
|
|
425
|
+
newAttrs,
|
|
426
|
+
}, deps);
|
|
427
|
+
}
|
|
428
|
+
});
|
|
370
429
|
}
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
}
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
};
|
|
374
433
|
}
|
|
375
434
|
|
|
376
435
|
try {
|