@ape-egg/vibe 1.1.2 → 1.2.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/CHANGELOG.md +78 -0
- package/README.md +30 -23
- package/index.js +96 -2
- package/package.json +2 -1
- package/runtime/affected.js +33 -19
- package/runtime/component-state.js +63 -0
- package/runtime/component.js +39 -2
- package/runtime/conditionals.js +4 -4
- package/runtime/constants.js +13 -0
- package/runtime/hydrate.js +17 -20
- package/runtime/index.js +115 -49
- package/runtime/iterate.js +12 -4
- package/runtime/parse.js +48 -2
- package/runtime/scope.js +70 -0
- package/runtime/state.js +10 -5
- package/runtime/utils.js +72 -5
- /package/{runtime/vibe.css → vibe.css} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,83 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.2.0] - 2026-02-03
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Component state isolation**: Components now have their own isolated state using `<script type="component">` blocks
|
|
8
|
+
- Each component gets a unique ID (`_cTIMESTAMP_RANDOM`) automatically generated and stored in `data-vibe-component-id` attribute
|
|
9
|
+
- Component state lives at `$[componentId].property` in the global state object
|
|
10
|
+
- Variable declarations (`let count = 0`) are automatically transformed to property assignments (`this.count = 0`)
|
|
11
|
+
- Clean separation between global state and component-specific state
|
|
12
|
+
- Example component (subject to change!):
|
|
13
|
+
```html
|
|
14
|
+
<script type="component">
|
|
15
|
+
let count = 0;
|
|
16
|
+
let increment = () => { this.count++; };
|
|
17
|
+
</script>
|
|
18
|
+
<button onclick="this.increment()">Clicked @[this.count] times</button>
|
|
19
|
+
```
|
|
20
|
+
- **`this.property` syntax**: Reference component-scoped state from anywhere inside a component
|
|
21
|
+
- Works in bindings: `@[this.count]`
|
|
22
|
+
- Works in attributes: `value="@[this.inputValue]"`
|
|
23
|
+
- Works in event handlers: `onclick="this.increment()"`
|
|
24
|
+
- Works in name bindings: `@[this.iconName]`
|
|
25
|
+
- Works in conditionals: `<!-- if this.isVisible -->`
|
|
26
|
+
- Works in iterations: `<!-- each this.items as item -->`
|
|
27
|
+
- Runtime automatically resolves `this.property` → `$['componentId'].property`
|
|
28
|
+
- **Component script execution**: `<script type="component">` blocks execute in their own scope
|
|
29
|
+
- Scripts run when component HTML is fetched (runtime `<component>` resolution)
|
|
30
|
+
- State is registered in global `$` object under component ID
|
|
31
|
+
- All siblings after the script tag inherit the component ID via `data-vibe-component-id` attribute
|
|
32
|
+
- Multiple component scripts in same HTML create separate component instances with unique IDs
|
|
33
|
+
- **Props and slots integration**: Component state works seamlessly with existing `<component>` features
|
|
34
|
+
- Props can set component state: `<component src="/card.html" theme="@[userTheme]">`
|
|
35
|
+
- Props work with `this.` references: `@[this.theme]` inside card.html
|
|
36
|
+
- Slots work inside component-scoped HTML
|
|
37
|
+
- Components can be nested with isolated state at each level
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- **Component ID tagging**: DOM elements are now tagged with `data-vibe-component-id` during component processing (previously only scripts had this attribute)
|
|
42
|
+
- All siblings after a `<script type="component">` get tagged with the same component ID
|
|
43
|
+
- Tagging stops when hitting another component script or end of HTML
|
|
44
|
+
- Enables `this.property` resolution in any context (bindings, events, conditionals, iterations)
|
|
45
|
+
- **Event handler rewriting**: Event handlers with `this.property` are now rewritten at parse time
|
|
46
|
+
- `onclick="this.increment()"` → `onclick="$['_c123_abc'].increment()"`
|
|
47
|
+
- DOM properties (like `this.value`, `this.checked`) are preserved and not rewritten
|
|
48
|
+
- Prevents conflicts between component state access and native DOM properties
|
|
49
|
+
|
|
50
|
+
### Technical Details
|
|
51
|
+
|
|
52
|
+
- **Component state lifecycle**:
|
|
53
|
+
1. `<component src="/path.html">` fetches HTML
|
|
54
|
+
2. HTML is parsed in temporary container
|
|
55
|
+
3. `<script type="component">` blocks are found and executed
|
|
56
|
+
4. Each script generates unique component ID
|
|
57
|
+
5. Script and following siblings are tagged with `data-vibe-component-id`
|
|
58
|
+
6. Component state is registered at `$[componentId]`
|
|
59
|
+
7. Props are applied (with `this.` reference rewriting)
|
|
60
|
+
8. Slots are replaced
|
|
61
|
+
9. Transformed HTML replaces `<component>` element
|
|
62
|
+
10. MutationObserver triggers reactive hydration with component context
|
|
63
|
+
- **`this.property` resolution**: Helper function `resolveThisPath()` in utils.js walks up DOM tree to find nearest `data-vibe-component-id`, then rewrites path from `this.property` → `componentId.property`
|
|
64
|
+
- **Expression evaluation**: `evalInScope()` in utils.js handles both global (`$.property`) and component-scoped (`$['componentId'].property`) state access, with case-insensitive fallback for HTML-lowercased attribute names
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## [1.1.3] - 2026-02-02
|
|
69
|
+
|
|
70
|
+
### Added
|
|
71
|
+
|
|
72
|
+
- **MutationObserver performance optimization**: Fast filter with short-circuit evaluation
|
|
73
|
+
- New `shouldProcessNode()` function checks for Vibe syntax before expensive processing
|
|
74
|
+
- Short-circuits on first match: most Vibe nodes contain `@[`, so check exits immediately
|
|
75
|
+
- Filters out third-party framework mutations (React, Vue, etc.) with cheap string operations
|
|
76
|
+
- Only walks DOM tree for nodes that actually contain Vibe syntax (`@[`, `<!-- each`, `<!-- if`, `<component>`)
|
|
77
|
+
- Enables efficient coexistence with other frameworks on the same page
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
3
81
|
## [1.1.2] - 2026-02-01
|
|
4
82
|
|
|
5
83
|
### Fixed
|
package/README.md
CHANGED
|
@@ -18,20 +18,18 @@ The core reactive runtime. Works directly in the browser without any build tools
|
|
|
18
18
|
|
|
19
19
|
```html
|
|
20
20
|
<html>
|
|
21
|
-
<head>
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
</head>
|
|
29
|
-
<body vibe>
|
|
30
|
-
|
|
31
|
-
<
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
</body>
|
|
21
|
+
<head>
|
|
22
|
+
<link rel="stylesheet" href="./node_modules/@ape-egg/vibe/vibe.css" />
|
|
23
|
+
<script type="module">
|
|
24
|
+
import state from './node_modules/@ape-egg/vibe/runtime/index.js';
|
|
25
|
+
// Attaches to element with attribute "vibe" by default
|
|
26
|
+
window.$ = state({ name: 'World', count: 0 });
|
|
27
|
+
</script>
|
|
28
|
+
</head>
|
|
29
|
+
<body vibe>
|
|
30
|
+
<h1>Hello, @[name]!</h1>
|
|
31
|
+
<button onclick="$.count++">Clicked @[count] times</button>
|
|
32
|
+
</body>
|
|
35
33
|
</html>
|
|
36
34
|
```
|
|
37
35
|
|
|
@@ -49,12 +47,14 @@ The CSS targets `[vibe]` and hides it until hydration completes. Once Vibe finis
|
|
|
49
47
|
Vibe supports bindings in three positions:
|
|
50
48
|
|
|
51
49
|
**Text content** — Inside element tags:
|
|
50
|
+
|
|
52
51
|
```html
|
|
53
52
|
<div>@[firstName] @[lastName]</div>
|
|
54
53
|
<h1>Hello, @[name]!</h1>
|
|
55
54
|
```
|
|
56
55
|
|
|
57
56
|
**Attribute values** — In attribute value position:
|
|
57
|
+
|
|
58
58
|
```html
|
|
59
59
|
<input value="@[username]" />
|
|
60
60
|
<div class="@[theme]" style="color: @[color]"></div>
|
|
@@ -62,15 +62,19 @@ Vibe supports bindings in three positions:
|
|
|
62
62
|
```
|
|
63
63
|
|
|
64
64
|
**Attribute names** — In attribute name position (useful for dynamic attributes):
|
|
65
|
+
|
|
65
66
|
```html
|
|
66
67
|
<icon @[iconName]></icon>
|
|
67
68
|
<button @[state]>Click me</button>
|
|
68
69
|
```
|
|
69
70
|
|
|
70
71
|
**CSS** — Bindings also work in style tags:
|
|
72
|
+
|
|
71
73
|
```html
|
|
72
74
|
<style>
|
|
73
|
-
.box {
|
|
75
|
+
.box {
|
|
76
|
+
background: @[themeColor];
|
|
77
|
+
}
|
|
74
78
|
</style>
|
|
75
79
|
```
|
|
76
80
|
|
|
@@ -78,7 +82,7 @@ Vibe supports bindings in three positions:
|
|
|
78
82
|
|
|
79
83
|
```html
|
|
80
84
|
<!-- each items as item, index -->
|
|
81
|
-
|
|
85
|
+
<li>@[index]: @[item]</li>
|
|
82
86
|
<!-- /each -->
|
|
83
87
|
```
|
|
84
88
|
|
|
@@ -86,9 +90,9 @@ Nested iteration with dot paths:
|
|
|
86
90
|
|
|
87
91
|
```html
|
|
88
92
|
<!-- each categories as category -->
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
93
|
+
<!-- each category.items as item -->
|
|
94
|
+
<span>@[item.name]</span>
|
|
95
|
+
<!-- /each -->
|
|
92
96
|
<!-- /each -->
|
|
93
97
|
```
|
|
94
98
|
|
|
@@ -96,9 +100,9 @@ Nested iteration with dot paths:
|
|
|
96
100
|
|
|
97
101
|
```html
|
|
98
102
|
<!-- if user.isAdmin -->
|
|
99
|
-
|
|
103
|
+
<admin-badge>Admin</admin-badge>
|
|
100
104
|
<!-- else -->
|
|
101
|
-
|
|
105
|
+
<span>User</span>
|
|
102
106
|
<!-- /if -->
|
|
103
107
|
```
|
|
104
108
|
|
|
@@ -126,9 +130,9 @@ Vibe uses recursive proxies to detect changes at any nesting level:
|
|
|
126
130
|
|
|
127
131
|
```javascript
|
|
128
132
|
// All of these trigger reactive updates:
|
|
129
|
-
$.user.name =
|
|
133
|
+
$.user.name = 'Alice';
|
|
130
134
|
$.todos[2].completed = true;
|
|
131
|
-
$.config.theme.colors.primary =
|
|
135
|
+
$.config.theme.colors.primary = '#007bff';
|
|
132
136
|
```
|
|
133
137
|
|
|
134
138
|
No need for immutable update patterns or spread operators. Just mutate and Vibe handles the rest.
|
|
@@ -211,6 +215,7 @@ Add to your `package.json`:
|
|
|
211
215
|
```
|
|
212
216
|
|
|
213
217
|
**Defaults** (when no config):
|
|
218
|
+
|
|
214
219
|
- `source`: `./`
|
|
215
220
|
- `output`: `./compiled`
|
|
216
221
|
- `components`: `<source>/components`
|
|
@@ -238,6 +243,7 @@ By default, the compiler creates deployable output by installing production depe
|
|
|
238
243
|
3. Removes `package.json` and lockfile from output (cleanup)
|
|
239
244
|
|
|
240
245
|
This ensures:
|
|
246
|
+
|
|
241
247
|
- Compiled output only includes runtime dependencies (from `dependencies`, not `devDependencies`)
|
|
242
248
|
- Local `node_modules` is never modified
|
|
243
249
|
- Faster than copying (no intermediate copy step)
|
|
@@ -294,6 +300,7 @@ cp target/release/vibe-compiler ../native/vibe-compiler-darwin-arm64
|
|
|
294
300
|
```
|
|
295
301
|
|
|
296
302
|
Supported platforms:
|
|
303
|
+
|
|
297
304
|
- `vibe-compiler-darwin-arm64` (macOS Apple Silicon) ✅ Included
|
|
298
305
|
- `vibe-compiler-darwin-x64` (macOS Intel)
|
|
299
306
|
- `vibe-compiler-linux-x64`
|
package/index.js
CHANGED
|
@@ -1,2 +1,96 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
1
|
+
// Universal entry point for Vibe
|
|
2
|
+
// Usage: <script src="vibe/index.js">$.count = 0;</script> (global)
|
|
3
|
+
// <script src="vibe/index.js">let count = 0;</script> (component)
|
|
4
|
+
|
|
5
|
+
(async () => {
|
|
6
|
+
const script = document.currentScript;
|
|
7
|
+
const scriptContent = script?.textContent?.trim() || '';
|
|
8
|
+
|
|
9
|
+
// Determine if this is component state (has let/const/var) or global state (uses $)
|
|
10
|
+
const hasDeclarations = /\b(let|const|var)\s+\w+/.test(scriptContent);
|
|
11
|
+
const isComponent = hasDeclarations;
|
|
12
|
+
|
|
13
|
+
// Boot Vibe if not already initialized or booting
|
|
14
|
+
if (!window.__vibeInitialized && !window.__vibeBooting) {
|
|
15
|
+
window.__vibeBooting = true;
|
|
16
|
+
|
|
17
|
+
// Process ALL scripts on the page BEFORE booting
|
|
18
|
+
const { generateComponentId, executeComponentScript } = await import('./runtime/component-state.js');
|
|
19
|
+
const allComponentScripts = document.querySelectorAll('script[src*="index.js"]');
|
|
20
|
+
const initialState = {};
|
|
21
|
+
const globalStateScripts = [];
|
|
22
|
+
|
|
23
|
+
allComponentScripts.forEach((s) => {
|
|
24
|
+
const content = s.textContent?.trim() || '';
|
|
25
|
+
if (!content) return;
|
|
26
|
+
|
|
27
|
+
const hasDecl = /\b(let|const|var)\s+\w+/.test(content);
|
|
28
|
+
if (hasDecl) {
|
|
29
|
+
// This is a component script
|
|
30
|
+
const componentId = generateComponentId();
|
|
31
|
+
|
|
32
|
+
// Tag script and siblings
|
|
33
|
+
s.setAttribute('data-vibe-component-id', componentId);
|
|
34
|
+
s.setAttribute('type', 'component');
|
|
35
|
+
|
|
36
|
+
let sibling = s.nextElementSibling;
|
|
37
|
+
while (sibling) {
|
|
38
|
+
if (sibling.tagName === 'SCRIPT' && /\b(let|const|var)\s+\w+/.test(sibling.textContent || '')) {
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
sibling.setAttribute('data-vibe-component-id', componentId);
|
|
42
|
+
sibling = sibling.nextElementSibling;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Execute component script to get state
|
|
46
|
+
const componentState = executeComponentScript(content);
|
|
47
|
+
initialState[componentId] = componentState;
|
|
48
|
+
} else {
|
|
49
|
+
// This is a global state script - save for later
|
|
50
|
+
globalStateScripts.push(content);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Execute global state scripts into initialState BEFORE booting
|
|
55
|
+
// This ensures global state is available during initial parse/hydrate
|
|
56
|
+
if (globalStateScripts.length > 0) {
|
|
57
|
+
const captured = {};
|
|
58
|
+
const fakeState = new Proxy(captured, {
|
|
59
|
+
set(target, key, value) {
|
|
60
|
+
target[key] = value;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
globalStateScripts.forEach(code => {
|
|
66
|
+
const $ = fakeState;
|
|
67
|
+
eval(code);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Merge global state into initialState
|
|
71
|
+
Object.assign(initialState, captured);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Boot Vibe with both component and global states
|
|
75
|
+
const { default: main } = await import('./runtime/index.js');
|
|
76
|
+
window.$ = main(initialState, 'vibe', {});
|
|
77
|
+
window.__vibeInitialized = true;
|
|
78
|
+
window.__vibeBooting = false;
|
|
79
|
+
} else {
|
|
80
|
+
// Vibe already booted - just execute global code if any
|
|
81
|
+
if (window.__vibeBooting) {
|
|
82
|
+
await new Promise(resolve => {
|
|
83
|
+
const check = setInterval(() => {
|
|
84
|
+
if (window.__vibeInitialized) {
|
|
85
|
+
clearInterval(check);
|
|
86
|
+
resolve();
|
|
87
|
+
}
|
|
88
|
+
}, 10);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!isComponent && scriptContent) {
|
|
93
|
+
eval(scriptContent);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
})();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ape-egg/vibe",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Runtime-first reactivity with optional compiler",
|
|
6
6
|
"main": "index.js",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"index.js",
|
|
18
|
+
"vibe.css",
|
|
18
19
|
"runtime/",
|
|
19
20
|
"compiler/bin/",
|
|
20
21
|
"compiler/native/",
|
package/runtime/affected.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { resolvePath, deepEqual } from './iteration-utils.js';
|
|
2
2
|
import { extractDependencies } from './conditionals.js';
|
|
3
3
|
import { BINDING_REGEX } from './constants.js';
|
|
4
|
-
import { evalInScope } from './utils.js';
|
|
4
|
+
import { evalInScope, resolveThisPath } from './utils.js';
|
|
5
5
|
|
|
6
6
|
// Evaluate conditional expression
|
|
7
|
-
const evaluateCondition = (expression, state) => !!evalInScope(expression, state);
|
|
7
|
+
const evaluateCondition = (expression, state, element = null) => !!evalInScope(expression, state, element);
|
|
8
8
|
|
|
9
9
|
// Helper function to check if a match references a specific key
|
|
10
10
|
const matchesKey = (matchStr, key) => matchStr === key || matchStr.startsWith(key + '.');
|
|
@@ -12,8 +12,11 @@ const matchesKey = (matchStr, key) => matchStr === key || matchStr.startsWith(ke
|
|
|
12
12
|
const recursive = (tree, state, newState, affected, scopedStateForHydration = null) => {
|
|
13
13
|
// Handle iteration nodes specially
|
|
14
14
|
if (tree.type === 'iteration') {
|
|
15
|
-
|
|
16
|
-
const
|
|
15
|
+
// Handle this.property for component-scoped arrays
|
|
16
|
+
const arrayPath = resolveThisPath(tree.meta.arrayPath, tree.meta.startComment?.parentElement);
|
|
17
|
+
|
|
18
|
+
const oldArray = resolvePath(state, arrayPath);
|
|
19
|
+
const newArray = resolvePath(newState, arrayPath);
|
|
17
20
|
|
|
18
21
|
// Fast path: reference comparison (arrays are typically replaced, not mutated)
|
|
19
22
|
// This avoids expensive O(n) deepEqual for large arrays
|
|
@@ -34,7 +37,8 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
34
37
|
// Use the instance's scoped state (includes item, index, etc.)
|
|
35
38
|
// Merge newState into scopedState to get updated global values
|
|
36
39
|
const mergedNewState = { ...instance.scopedState, ...newState };
|
|
37
|
-
|
|
40
|
+
// Pass the Proxy as scopedState so evalInScope can access iteration variables
|
|
41
|
+
recursive(instance.tree, instance.scopedState, mergedNewState, affected, instance.scopedState);
|
|
38
42
|
}
|
|
39
43
|
}
|
|
40
44
|
}
|
|
@@ -44,8 +48,8 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
44
48
|
|
|
45
49
|
// Handle conditional nodes specially
|
|
46
50
|
if (tree.type === 'conditional') {
|
|
47
|
-
const oldValue = evaluateCondition(tree.meta.expression, state);
|
|
48
|
-
const newValue = evaluateCondition(tree.meta.expression, newState);
|
|
51
|
+
const oldValue = evaluateCondition(tree.meta.expression, state, tree.meta.startComment?.parentElement);
|
|
52
|
+
const newValue = evaluateCondition(tree.meta.expression, newState, tree.meta.startComment?.parentElement);
|
|
49
53
|
|
|
50
54
|
// Check if condition result changed
|
|
51
55
|
if (oldValue !== newValue) {
|
|
@@ -83,21 +87,24 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
83
87
|
const checkedMatches = [];
|
|
84
88
|
|
|
85
89
|
for (const m of matches) {
|
|
86
|
-
|
|
90
|
+
// Resolve this.property to componentId.property
|
|
91
|
+
const resolvedInner = resolveThisPath(m.inner, tree.element);
|
|
92
|
+
|
|
93
|
+
const noMatch = !shallowState.some((key) => matchesKey(resolvedInner, key));
|
|
87
94
|
|
|
88
95
|
let shouldAffect = false;
|
|
89
96
|
let relevantKeys = [];
|
|
90
97
|
|
|
91
98
|
if (isInitialHydration) {
|
|
92
99
|
// Initial hydration: affect all matched keys
|
|
93
|
-
relevantKeys = shallowNewState.filter((key) => matchesKey(
|
|
100
|
+
relevantKeys = shallowNewState.filter((key) => matchesKey(resolvedInner, key));
|
|
94
101
|
shouldAffect = noMatch || relevantKeys.length > 0;
|
|
95
102
|
} else {
|
|
96
103
|
// Update: only affect if value changed
|
|
97
104
|
const changedKeys = shallowNewState.filter((key) =>
|
|
98
|
-
matchesKey(
|
|
105
|
+
matchesKey(resolvedInner, key) && state[key] !== newState[key]
|
|
99
106
|
);
|
|
100
|
-
relevantKeys = changedKeys.length > 0 ? changedKeys : shallowState.filter((key) => matchesKey(
|
|
107
|
+
relevantKeys = changedKeys.length > 0 ? changedKeys : shallowState.filter((key) => matchesKey(resolvedInner, key));
|
|
101
108
|
shouldAffect = noMatch || changedKeys.length > 0;
|
|
102
109
|
}
|
|
103
110
|
|
|
@@ -119,6 +126,7 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
119
126
|
input: m.input,
|
|
120
127
|
matches: m.matches,
|
|
121
128
|
element: tree.element,
|
|
129
|
+
textNode: tree.textNode, // Reference to specific text node (prevents wiping children)
|
|
122
130
|
scopedState: scopedStateForHydration, // Pass scoped state from iteration context
|
|
123
131
|
});
|
|
124
132
|
}
|
|
@@ -140,18 +148,21 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
140
148
|
}
|
|
141
149
|
|
|
142
150
|
for (const m of attrMatches) {
|
|
143
|
-
|
|
151
|
+
// Resolve this.property to componentId.property
|
|
152
|
+
const resolvedInner = resolveThisPath(m.inner, tree.element);
|
|
153
|
+
|
|
154
|
+
const noMatch = !shallowState.some((key) => matchesKey(resolvedInner, key));
|
|
144
155
|
|
|
145
156
|
let shouldAffect = false;
|
|
146
157
|
|
|
147
158
|
if (isInitialHydration) {
|
|
148
159
|
// Initial hydration: affect all matched keys
|
|
149
|
-
const newMatches = shallowNewState.filter((key) => matchesKey(
|
|
160
|
+
const newMatches = shallowNewState.filter((key) => matchesKey(resolvedInner, key));
|
|
150
161
|
shouldAffect = noMatch || newMatches.length > 0;
|
|
151
162
|
} else {
|
|
152
163
|
// Update: only affect if value changed
|
|
153
164
|
const changedKeys = shallowNewState.filter((key) =>
|
|
154
|
-
matchesKey(
|
|
165
|
+
matchesKey(resolvedInner, key) && state[key] !== newState[key]
|
|
155
166
|
);
|
|
156
167
|
shouldAffect = noMatch || changedKeys.length > 0;
|
|
157
168
|
}
|
|
@@ -162,7 +173,7 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
162
173
|
attrName,
|
|
163
174
|
attrValue,
|
|
164
175
|
matchOuter: m.outer,
|
|
165
|
-
matchInner: m.inner,
|
|
176
|
+
matchInner: m.inner, // Keep original, evalInScope will resolve this.
|
|
166
177
|
element: tree.element,
|
|
167
178
|
scopedState: scopedStateForHydration, // Pass scoped state from iteration context
|
|
168
179
|
});
|
|
@@ -186,16 +197,19 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
186
197
|
}
|
|
187
198
|
|
|
188
199
|
for (const m of nameMatches) {
|
|
189
|
-
|
|
200
|
+
// Resolve this.property to componentId.property
|
|
201
|
+
const resolvedInner = resolveThisPath(m.inner, tree.element);
|
|
202
|
+
|
|
203
|
+
const noMatch = !shallowState.some((key) => matchesKey(resolvedInner, key));
|
|
190
204
|
|
|
191
205
|
let shouldAffect = false;
|
|
192
206
|
|
|
193
207
|
if (isInitialHydration) {
|
|
194
|
-
const newMatches = shallowNewState.filter((key) => matchesKey(
|
|
208
|
+
const newMatches = shallowNewState.filter((key) => matchesKey(resolvedInner, key));
|
|
195
209
|
shouldAffect = noMatch || newMatches.length > 0;
|
|
196
210
|
} else {
|
|
197
211
|
const changedKeys = shallowNewState.filter((key) =>
|
|
198
|
-
matchesKey(
|
|
212
|
+
matchesKey(resolvedInner, key) && state[key] !== newState[key]
|
|
199
213
|
);
|
|
200
214
|
shouldAffect = noMatch || changedKeys.length > 0;
|
|
201
215
|
}
|
|
@@ -205,7 +219,7 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
205
219
|
type: 'nameBinding',
|
|
206
220
|
nameBinding,
|
|
207
221
|
matchOuter: m.outer,
|
|
208
|
-
matchInner: m.inner,
|
|
222
|
+
matchInner: m.inner, // Keep original, evalInScope will resolve this.
|
|
209
223
|
element: tree.element,
|
|
210
224
|
scopedState: scopedStateForHydration,
|
|
211
225
|
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Shared utility for processing component state from <script type="component">
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate unique component ID
|
|
5
|
+
*/
|
|
6
|
+
export const generateComponentId = () => {
|
|
7
|
+
return `_c${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Transform variable declarations to property assignments
|
|
12
|
+
* let count = 0; → this.count = 0;
|
|
13
|
+
*/
|
|
14
|
+
const transformDeclarations = (code) => {
|
|
15
|
+
return code
|
|
16
|
+
// Transform: let varName = value;
|
|
17
|
+
.replace(/\b(let|const|var)\s+(\w+)\s*=\s*([^;]+);/g, 'this.$2 = $3;')
|
|
18
|
+
// Transform: let varName;
|
|
19
|
+
.replace(/\b(let|const|var)\s+(\w+)\s*;/g, 'this.$2 = undefined;');
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Execute component script and return state object
|
|
24
|
+
* @param {string} scriptContent - JavaScript code to execute
|
|
25
|
+
* @returns {object} - Component state object
|
|
26
|
+
*/
|
|
27
|
+
export const executeComponentScript = (scriptContent) => {
|
|
28
|
+
const componentState = {};
|
|
29
|
+
|
|
30
|
+
if (!scriptContent || !scriptContent.trim()) {
|
|
31
|
+
return componentState;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
// Transform declarations to property assignments
|
|
36
|
+
const transformed = transformDeclarations(scriptContent);
|
|
37
|
+
|
|
38
|
+
// Execute script with 'this' = componentState
|
|
39
|
+
const fn = new Function(transformed);
|
|
40
|
+
fn.call(componentState);
|
|
41
|
+
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error('[vibe] Error executing component script:', error);
|
|
44
|
+
console.error('[vibe] Script content:', scriptContent);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return componentState;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Register component state in global store
|
|
52
|
+
* @param {string} componentId - Unique component identifier
|
|
53
|
+
* @param {object} state - Component state object
|
|
54
|
+
* @param {object} globalState - Global $ object
|
|
55
|
+
*/
|
|
56
|
+
export const registerComponentState = (componentId, state, globalState) => {
|
|
57
|
+
if (!globalState[componentId]) {
|
|
58
|
+
globalState[componentId] = state;
|
|
59
|
+
} else {
|
|
60
|
+
// Component already registered, merge new state
|
|
61
|
+
Object.assign(globalState[componentId], state);
|
|
62
|
+
}
|
|
63
|
+
};
|
package/runtime/component.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { debugLog } from './debug.js';
|
|
2
2
|
import { PHASE_FETCH } from './constants.js';
|
|
3
3
|
import { evalInScope } from './utils.js';
|
|
4
|
+
import { generateComponentId, executeComponentScript } from './component-state.js';
|
|
4
5
|
|
|
5
6
|
// Helper to escape regex special characters
|
|
6
7
|
const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -52,8 +53,44 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
52
53
|
fetch(src, { signal: controller.signal })
|
|
53
54
|
.then((r) => r.text())
|
|
54
55
|
.then((html) => {
|
|
55
|
-
//
|
|
56
|
-
|
|
56
|
+
// Parse HTML in temporary container to process component scripts
|
|
57
|
+
const temp = document.createElement('div');
|
|
58
|
+
temp.innerHTML = html;
|
|
59
|
+
|
|
60
|
+
// Process any <script type="component"> elements
|
|
61
|
+
const componentScripts = temp.querySelectorAll('script[type="component"]');
|
|
62
|
+
componentScripts.forEach((script) => {
|
|
63
|
+
const scriptContent = script.textContent?.trim() || '';
|
|
64
|
+
if (!scriptContent) return;
|
|
65
|
+
|
|
66
|
+
// Generate component ID
|
|
67
|
+
const componentId = generateComponentId();
|
|
68
|
+
|
|
69
|
+
// Tag script element
|
|
70
|
+
script.setAttribute('data-vibe-component-id', componentId);
|
|
71
|
+
|
|
72
|
+
// Tag following siblings with this component ID
|
|
73
|
+
let sibling = script.nextElementSibling;
|
|
74
|
+
while (sibling) {
|
|
75
|
+
// Stop if we hit another component script
|
|
76
|
+
if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'component') {
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
sibling.setAttribute('data-vibe-component-id', componentId);
|
|
80
|
+
sibling = sibling.nextElementSibling;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Execute component script to get state
|
|
84
|
+
const componentState = executeComponentScript(scriptContent);
|
|
85
|
+
|
|
86
|
+
// Register in global state
|
|
87
|
+
if (window.$) {
|
|
88
|
+
window.$[componentId] = componentState;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Get transformed HTML from temp container
|
|
93
|
+
let transformedHtml = temp.innerHTML;
|
|
57
94
|
|
|
58
95
|
// Replace props
|
|
59
96
|
Object.entries(props).forEach(([propName, propValue]) => {
|
package/runtime/conditionals.js
CHANGED
|
@@ -5,7 +5,7 @@ import { createScopedState, renderAllIterations, initializeBlock } from './itera
|
|
|
5
5
|
import { evalInScope } from './utils.js';
|
|
6
6
|
|
|
7
7
|
// Evaluate conditional expression in state context
|
|
8
|
-
const evaluateCondition = (expression, state) => !!evalInScope(expression, state);
|
|
8
|
+
const evaluateCondition = (expression, state, element = null) => !!evalInScope(expression, state, element);
|
|
9
9
|
|
|
10
10
|
// Extract state dependencies from an expression
|
|
11
11
|
// e.g., "count > 5" → ["count"]
|
|
@@ -78,7 +78,7 @@ export const renderConditional = (node, state, manifest, parentScope = {}) => {
|
|
|
78
78
|
startComment.__vibeRendered = true;
|
|
79
79
|
|
|
80
80
|
// Evaluate condition with current state
|
|
81
|
-
const conditionResult = evaluateCondition(expression, state);
|
|
81
|
+
const conditionResult = evaluateCondition(expression, state, startComment.parentElement);
|
|
82
82
|
|
|
83
83
|
// Determine which branch to mount
|
|
84
84
|
const branchToMount = conditionResult ? branches.if : branches.else;
|
|
@@ -154,7 +154,7 @@ const unmountBranch = (node) => {
|
|
|
154
154
|
|
|
155
155
|
// Update conditional when dependencies change
|
|
156
156
|
export const updateConditional = (node, newState, oldState, manifest, parentScope = {}) => {
|
|
157
|
-
const { expression, branches } = node.meta;
|
|
157
|
+
const { expression, branches, startComment } = node.meta;
|
|
158
158
|
|
|
159
159
|
// If not yet rendered, skip (renderConditional handles initial render)
|
|
160
160
|
if (!node.runtime.templateRemoved) {
|
|
@@ -162,7 +162,7 @@ export const updateConditional = (node, newState, oldState, manifest, parentScop
|
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
// Evaluate expression with new state
|
|
165
|
-
const newConditionResult = evaluateCondition(expression, newState);
|
|
165
|
+
const newConditionResult = evaluateCondition(expression, newState, startComment?.parentElement);
|
|
166
166
|
const newBranchData = newConditionResult ? branches.if : branches.else;
|
|
167
167
|
|
|
168
168
|
// Check if branch changed (compare references)
|
package/runtime/constants.js
CHANGED
|
@@ -156,6 +156,19 @@ export const VALUE_ATTRS = [
|
|
|
156
156
|
// Properties that should be set directly on the DOM element (not as attributes)
|
|
157
157
|
export const DOM_PROPERTIES = ['value', 'checked', 'selected'];
|
|
158
158
|
|
|
159
|
+
// DOM element properties that should NOT be rewritten in event handlers
|
|
160
|
+
// When parsing onclick="this.value = this.inputValue", preserve this.value (DOM) but rewrite this.inputValue (component state)
|
|
161
|
+
export const DOM_ELEMENT_PROPERTIES = new Set([
|
|
162
|
+
'value', 'checked', 'selected', 'disabled', 'readOnly', 'files',
|
|
163
|
+
'tagName', 'nodeName', 'nodeType', 'classList', 'className', 'id',
|
|
164
|
+
'innerHTML', 'outerHTML', 'textContent', 'innerText',
|
|
165
|
+
'parentElement', 'parentNode', 'nextSibling', 'previousSibling',
|
|
166
|
+
'firstChild', 'lastChild', 'children', 'childNodes',
|
|
167
|
+
'offsetWidth', 'offsetHeight', 'clientWidth', 'clientHeight',
|
|
168
|
+
'scrollTop', 'scrollLeft', 'scrollWidth', 'scrollHeight',
|
|
169
|
+
'style', 'dataset', 'attributes'
|
|
170
|
+
]);
|
|
171
|
+
|
|
159
172
|
// Regex for matching reactive bindings (@[expression])
|
|
160
173
|
// Supports one level of nested brackets: @[items[0]] or @[obj[key]]
|
|
161
174
|
export const BINDING_REGEX = /\@\[((?:[^\[\]]|\[[^\]]*\])+)\]/g;
|