@ape-egg/vibe 3.0.5 → 4.0.1
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 +2 -2
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/runtime/component.js +17 -4
- package/runtime/constants.js +1 -40
- package/runtime/iterate.js +30 -0
- package/runtime/parse.js +9 -7
- package/runtime/this-scope.js +53 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Vibe
|
|
2
2
|
|
|
3
|
-
**Version
|
|
3
|
+
**Version 4.0.1** — Runtime-first reactivity for plain HTML. Drop a script tag into any page and get reactive bindings, control flow, and URL-loaded components — no build step required. Compile later if you want; the compiler is a separate, optional package (`@ape-egg/vibe-compiler`).
|
|
4
4
|
|
|
5
5
|
## Security model & CSP
|
|
6
6
|
|
|
@@ -196,7 +196,7 @@ How it works:
|
|
|
196
196
|
|
|
197
197
|
1. `component({...})` generates a unique id (e.g. `_c0`, `_c1`) and registers the state at `$[id]`
|
|
198
198
|
2. The `<script>` and every following sibling is tagged with `data-vibe-component-id="<id>"`
|
|
199
|
-
3. Inside that subtree, `@[this.X.Y]` is rewritten to `@[_c0.X.Y]
|
|
199
|
+
3. Inside that subtree, `@[this.X.Y]` is rewritten to `@[_c0.X.Y]`, and `this.` in event handlers resolves at fire time by declared keys: names registered in `component({...})` address `$[id]` (`onclick="this.method()"`, `this.count++`), every other name reaches the native element (`this.value`, `this.closest(...)`, `this.focus()`). `$.this.X = ...` is always an explicit state write
|
|
200
200
|
4. When the component leaves the DOM, its state entry is freed automatically
|
|
201
201
|
|
|
202
202
|
Multi-segment paths (`@[this.user.profile.name]`), conditionals (`<!-- if this.editing -->`), and iterations (`<!-- each this.items as item -->`) all resolve against the component's bucket. Global `$` and component `this.X` coexist freely.
|
package/llms.txt
CHANGED
|
@@ -290,7 +290,7 @@ Use standard inline event handlers — they execute against `$` directly:
|
|
|
290
290
|
<form onsubmit="event.preventDefault(); handleSubmit()">
|
|
291
291
|
```
|
|
292
292
|
|
|
293
|
-
Inside a component, `this.X`
|
|
293
|
+
Inside a component, `this.X` resolves at fire time: keys declared in `component({...})` reference the component's bucket, every other name reaches the native DOM element (`this.value`, `this.dataset`, `this.nextElementSibling`, `this.closest(...)`, `this.focus()` — the whole DOM API). Declaring a key claims that name for state within the component's handlers; reach the shadowed element property through `event.currentTarget` if you need both.
|
|
294
294
|
|
|
295
295
|
## Dynamic Elements
|
|
296
296
|
|
package/package.json
CHANGED
package/runtime/component.js
CHANGED
|
@@ -484,6 +484,16 @@ export const forceRemount = (el, debug = false) => {
|
|
|
484
484
|
processSingle(el, debug);
|
|
485
485
|
};
|
|
486
486
|
|
|
487
|
+
// A src still carrying an @[…] binding is not a URL — it's source material
|
|
488
|
+
// (an iteration or branch template the renderer hasn't consumed yet, or an
|
|
489
|
+
// outlet whose scope owner hasn't resolved it). Fetching it verbatim is never
|
|
490
|
+
// right: initializeBlock substitutes loop-scoped srcs, hydration remounts
|
|
491
|
+
// reactive outlets, and this pass only mounts real URLs.
|
|
492
|
+
const hasUnresolvedSrc = (el) => {
|
|
493
|
+
BINDING_REGEX.lastIndex = 0;
|
|
494
|
+
return BINDING_REGEX.test(el.getAttribute('src'));
|
|
495
|
+
};
|
|
496
|
+
|
|
487
497
|
// Check if an element is nested inside another unprocessed component[src]
|
|
488
498
|
const isNestedInUnprocessedComponent = (el, rootElement) => {
|
|
489
499
|
let parent = el.parentElement;
|
|
@@ -506,8 +516,10 @@ const isNestedInUnprocessedComponent = (el, rootElement) => {
|
|
|
506
516
|
// any trailing `.Y.Z` chain), so `@[this.user.name]` and `@[this.x + 1]` both
|
|
507
517
|
// resolve correctly. Outside bindings — i.e. event handler attribute bodies
|
|
508
518
|
// like `onclick="$.this.mode = 'edit'"` — only `$.this.X` writes are
|
|
509
|
-
// rewritten; bare `this.X`
|
|
510
|
-
//
|
|
519
|
+
// rewritten; bare `this.X` in event handlers is handled later by parse.js,
|
|
520
|
+
// which lowers it to `$this(this).X` — resolved at fire time by
|
|
521
|
+
// this-scope.js (declared state keys hit the bucket, everything else stays
|
|
522
|
+
// the native element).
|
|
511
523
|
const rewriteBindingsInString = (str, componentId) =>
|
|
512
524
|
str.replace(BINDING_REGEX, (match, expr) => {
|
|
513
525
|
const rewritten = expr.replace(THIS_PROP_REGEX, `${componentId}.$1`);
|
|
@@ -1048,11 +1060,12 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
1048
1060
|
return;
|
|
1049
1061
|
}
|
|
1050
1062
|
|
|
1051
|
-
// Only process top-level components
|
|
1063
|
+
// Only process top-level components with a fetchable src — skip unresolved
|
|
1064
|
+
// binding srcs (never fetchable), and skip those nested inside other
|
|
1052
1065
|
// unprocessed component[src] elements (they're slot content that will
|
|
1053
1066
|
// be revealed when the parent component finalizes).
|
|
1054
1067
|
const topLevel = Array.from(allComponents).filter(
|
|
1055
|
-
(el) => !isNestedInUnprocessedComponent(el, rootElement)
|
|
1068
|
+
(el) => !hasUnresolvedSrc(el) && !isNestedInUnprocessedComponent(el, rootElement)
|
|
1056
1069
|
);
|
|
1057
1070
|
|
|
1058
1071
|
if (topLevel.length === 0) {
|
package/runtime/constants.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The released version — check-release.js holds this in lockstep with
|
|
2
2
|
// package.json, the READMEs and the CHANGELOG.
|
|
3
|
-
export const VERSION = '
|
|
3
|
+
export const VERSION = '4.0.1';
|
|
4
4
|
|
|
5
5
|
// Debug logger name
|
|
6
6
|
export const DEBUGGER_NAME = '[vibe-debug]:';
|
|
@@ -176,45 +176,6 @@ export const VALUE_ATTRS = [
|
|
|
176
176
|
// Properties that should be set directly on the DOM element (not as attributes)
|
|
177
177
|
export const DOM_PROPERTIES = ['value', 'checked', 'selected'];
|
|
178
178
|
|
|
179
|
-
// DOM element properties that should NOT be rewritten in event handlers
|
|
180
|
-
// When parsing onclick="this.value = this.inputValue", preserve this.value (DOM) but rewrite this.inputValue (component state)
|
|
181
|
-
export const DOM_ELEMENT_PROPERTIES = new Set([
|
|
182
|
-
'value',
|
|
183
|
-
'checked',
|
|
184
|
-
'selected',
|
|
185
|
-
'disabled',
|
|
186
|
-
'readOnly',
|
|
187
|
-
'files',
|
|
188
|
-
'tagName',
|
|
189
|
-
'nodeName',
|
|
190
|
-
'nodeType',
|
|
191
|
-
'classList',
|
|
192
|
-
'className',
|
|
193
|
-
'id',
|
|
194
|
-
'innerHTML',
|
|
195
|
-
'outerHTML',
|
|
196
|
-
'textContent',
|
|
197
|
-
'innerText',
|
|
198
|
-
'parentElement',
|
|
199
|
-
'parentNode',
|
|
200
|
-
'nextSibling',
|
|
201
|
-
'previousSibling',
|
|
202
|
-
'firstChild',
|
|
203
|
-
'lastChild',
|
|
204
|
-
'children',
|
|
205
|
-
'childNodes',
|
|
206
|
-
'offsetWidth',
|
|
207
|
-
'offsetHeight',
|
|
208
|
-
'clientWidth',
|
|
209
|
-
'clientHeight',
|
|
210
|
-
'scrollTop',
|
|
211
|
-
'scrollLeft',
|
|
212
|
-
'scrollWidth',
|
|
213
|
-
'scrollHeight',
|
|
214
|
-
'style',
|
|
215
|
-
'dataset',
|
|
216
|
-
'attributes',
|
|
217
|
-
]);
|
|
218
179
|
|
|
219
180
|
// Regex for matching reactive bindings (@[expression])
|
|
220
181
|
// Supports nested brackets, single-quoted and double-quoted strings inside expressions:
|
package/runtime/iterate.js
CHANGED
|
@@ -493,6 +493,24 @@ const resolveSlotContentBindings = (el, scopedState, aliases) => {
|
|
|
493
493
|
}
|
|
494
494
|
};
|
|
495
495
|
|
|
496
|
+
// Substitute the loop-scoped `@[expr]` parts of a component's src with their
|
|
497
|
+
// values from this block's scope. Only exprs whose dependencies include a loop
|
|
498
|
+
// alias are resolved — everything else keeps its binding form (see the caller
|
|
499
|
+
// in initializeBlock for why). An expr that evaluates to undefined stays raw
|
|
500
|
+
// too, so the unresolved-src invariant keeps the wrapper unfetchable instead
|
|
501
|
+
// of composing a garbage URL.
|
|
502
|
+
const resolveScopedSrc = (el, scopedState, scopeKeys) => {
|
|
503
|
+
const src = el.getAttribute('src');
|
|
504
|
+
BINDING_REGEX.lastIndex = 0;
|
|
505
|
+
if (!BINDING_REGEX.test(src)) return;
|
|
506
|
+
const resolved = src.replace(BINDING_REGEX, (match, expr) =>
|
|
507
|
+
extractDependencies(expr).some((dep) => scopeKeys.has(dep))
|
|
508
|
+
? (evalInScope(expr, scopedState, el) ?? match)
|
|
509
|
+
: match,
|
|
510
|
+
);
|
|
511
|
+
if (resolved !== src) el.setAttribute('src', resolved);
|
|
512
|
+
};
|
|
513
|
+
|
|
496
514
|
// For <component src> elements inside an iteration instance, evaluate any
|
|
497
515
|
// `@[expr]` attribute bindings against the iteration's scoped state and route
|
|
498
516
|
// every resolved value through the global iteration-prop registry. The prop
|
|
@@ -1016,10 +1034,22 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null, c
|
|
|
1016
1034
|
// el.innerHTML (next microtask, when MutationObserver fires) the inactive branch templates
|
|
1017
1035
|
// would be gone. cloneNode(true) does not copy expando JS properties, so we must (re)capture
|
|
1018
1036
|
// _vibeSlotContent on every clone.
|
|
1037
|
+
//
|
|
1038
|
+
// Also resolve loop-scoped src bindings here, BEFORE parse() reads the
|
|
1039
|
+
// attribute: the loop locals die with this render, so a src composed from
|
|
1040
|
+
// them (`src="/x/@[item.slug].html"`) must become a literal URL now. Left as
|
|
1041
|
+
// a binding it registers in this block's tree, and the remount transport
|
|
1042
|
+
// (_vibeSrcBinding → data-vibe-src on the finalized wrapper) re-evaluates it
|
|
1043
|
+
// against GLOBAL state on reparse — clobbering the mounted row with a fetch
|
|
1044
|
+
// of a garbage URL. Exprs that read no loop alias stay raw: a reactive
|
|
1045
|
+
// outlet (`src="@[page.src]"`) keeps its global binding, and an alias owned
|
|
1046
|
+
// by a deeper loop resolves when that loop clones its own instances.
|
|
1019
1047
|
const components = parseContainer.querySelectorAll('component[src], div.component[src]');
|
|
1048
|
+
const scopeKeys = overlayKeysOf(scopedState);
|
|
1020
1049
|
for (let i = 0; i < components.length; i++) {
|
|
1021
1050
|
const el = components[i];
|
|
1022
1051
|
if (el._vibeSlotContent === undefined) el._vibeSlotContent = el.innerHTML;
|
|
1052
|
+
if (scopeKeys) resolveScopedSrc(el, scopedState, scopeKeys);
|
|
1023
1053
|
}
|
|
1024
1054
|
|
|
1025
1055
|
// Give this row its own component ids (compiled mode) before parse() reads the
|
package/runtime/parse.js
CHANGED
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
BINDING_REGEX,
|
|
10
10
|
CONDITIONAL_REGEX,
|
|
11
11
|
ITERATION_START_REGEX,
|
|
12
|
-
DOM_ELEMENT_PROPERTIES,
|
|
13
12
|
DEHYDRATE_CLASS_OR_ATTR,
|
|
14
13
|
THIS_PROP_REGEX,
|
|
15
14
|
STATE_THIS_PROP_REGEX,
|
|
@@ -17,6 +16,7 @@ import {
|
|
|
17
16
|
FETCH_SRC_ELEMENTS,
|
|
18
17
|
} from './constants.js';
|
|
19
18
|
import { rewriteHandlerAliases } from './loop-scope.js';
|
|
19
|
+
import './this-scope.js';
|
|
20
20
|
|
|
21
21
|
// Walks up the DOM for the nearest component wrapper tagged by component.js.
|
|
22
22
|
// Used to rewrite `this.property` in event handlers to the component's state path.
|
|
@@ -125,7 +125,11 @@ const captureAttributeBindings = (element, aliasSet) => {
|
|
|
125
125
|
|
|
126
126
|
// Event handlers get two compile-time rewrites, computed off the original
|
|
127
127
|
// value and written once:
|
|
128
|
-
// 1. `this
|
|
128
|
+
// 1. `this.` inside a component scope → `$this(this).` — resolved at
|
|
129
|
+
// FIRE time by this-scope.js: declared state keys hit the bucket,
|
|
130
|
+
// everything else stays the native element. Fire-time resolution is
|
|
131
|
+
// load-bearing: compiled pages register component state after the
|
|
132
|
+
// initial parse, so no key check is possible here.
|
|
129
133
|
// 2. bare loop-variable aliases → `$scope(this,'alias')` (loop-scoped
|
|
130
134
|
// handlers — only when an enclosing <!-- each --> alias is in scope).
|
|
131
135
|
if (attr.name.startsWith('on')) {
|
|
@@ -136,13 +140,11 @@ const captureAttributeBindings = (element, aliasSet) => {
|
|
|
136
140
|
// `$.this.X` (component-state write through the root) must be
|
|
137
141
|
// consumed as one reference BEFORE the bare `this.X` pass — that
|
|
138
142
|
// pass alone would leave the `$.` prefix behind and produce
|
|
139
|
-
// `$.$
|
|
140
|
-
// already rewritten by component.js; compiled pages inline the
|
|
143
|
+
// `$.$this(this).X`. Runtime-fetched components arrive with this
|
|
144
|
+
// form already rewritten by component.js; compiled pages inline the
|
|
141
145
|
// authored form, so parse meets it raw.
|
|
142
146
|
v = v.replace(STATE_THIS_PROP_REGEX, (_, prop) => `$['${componentId}'].${prop}`);
|
|
143
|
-
v = v.replace(THIS_PROP_REGEX, (
|
|
144
|
-
DOM_ELEMENT_PROPERTIES.has(prop) ? match : `$['${componentId}'].${prop}`,
|
|
145
|
-
);
|
|
147
|
+
v = v.replace(THIS_PROP_REGEX, (_, prop) => `$this(this).${prop}`);
|
|
146
148
|
}
|
|
147
149
|
}
|
|
148
150
|
if (aliasSet && aliasSet.size > 0) {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Event-handler `this.` resolution.
|
|
2
|
+
//
|
|
3
|
+
// parse.js rewrites `this.` in on* handlers inside a component scope to
|
|
4
|
+
// `$this(this).` — this module is that `$this`. The returned proxy decides at
|
|
5
|
+
// FIRE time, per access: a key declared in the component's state routes to
|
|
6
|
+
// the state bucket; anything else routes to the native element (functions
|
|
7
|
+
// bound so DOM methods keep their receiver, state methods left raw so `this`
|
|
8
|
+
// inside them stays the proxy and nested writes stay reactive).
|
|
9
|
+
//
|
|
10
|
+
// The decision must happen at fire time, not parse time: compiled pages
|
|
11
|
+
// register component state AFTER the initial parse (boot runs
|
|
12
|
+
// executeCompiledComponentScripts post-parse, pre-hydrate), so a parse-time
|
|
13
|
+
// key check would see empty buckets and mis-route every handler.
|
|
14
|
+
//
|
|
15
|
+
// Same delivery pattern as loop-scope's `$scope`: a global resolver keeps the
|
|
16
|
+
// handler a readable native `on*` attribute.
|
|
17
|
+
|
|
18
|
+
import { findComponentIdForElement } from './utils.js';
|
|
19
|
+
|
|
20
|
+
const bucketFor = (element) => {
|
|
21
|
+
const componentId = findComponentIdForElement(element);
|
|
22
|
+
return componentId ? globalThis.$?.[componentId] : undefined;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const resolveThisScope = (element) =>
|
|
26
|
+
new Proxy(element, {
|
|
27
|
+
get(el, prop) {
|
|
28
|
+
const bucket = bucketFor(el);
|
|
29
|
+
if (bucket && prop in bucket) return bucket[prop];
|
|
30
|
+
const value = el[prop];
|
|
31
|
+
return typeof value === 'function' ? value.bind(el) : value;
|
|
32
|
+
},
|
|
33
|
+
set(el, prop, value) {
|
|
34
|
+
const bucket = bucketFor(el);
|
|
35
|
+
if (bucket && prop in bucket) {
|
|
36
|
+
bucket[prop] = value;
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
if (bucket && globalThis.__vibe?.debug && !(prop in el)) {
|
|
40
|
+
console.warn(
|
|
41
|
+
`[vibe] handler assigned undeclared "this.${prop}" — it lands on the element, not component state. Declare it in component({...}) to make it reactive.`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
el[prop] = value;
|
|
45
|
+
return true;
|
|
46
|
+
},
|
|
47
|
+
has(el, prop) {
|
|
48
|
+
const bucket = bucketFor(el);
|
|
49
|
+
return (bucket && prop in bucket) || prop in el;
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
globalThis.$this = resolveThisScope;
|