@ape-egg/vibe 1.7.2 → 1.9.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 +139 -0
- package/README.md +28 -0
- package/boot.js +5 -1
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +85 -496
- package/compiler/src/Cargo.toml +1 -1
- package/compiler/src/compiler/compile.rs +3 -11
- package/compiler/src/parser/html.rs +15 -15
- package/index.js +15 -0
- package/package.json +2 -2
- package/runtime/_vibe-compiled-iteration-batch.js +18 -9
- package/runtime/affected.js +54 -16
- package/runtime/component.js +287 -110
- package/runtime/conditionals.js +99 -7
- package/runtime/constants.js +10 -6
- package/runtime/index.js +142 -47
- package/runtime/iterate.js +364 -142
- package/runtime/iteration-utils.js +5 -1
- package/runtime/manifest.js +2 -1
- package/runtime/parse.js +67 -80
- package/runtime/pre-compiled-iterations.js +34 -21
- package/runtime/pre-compiled-manifest.js +38 -13
- package/runtime/reconcile.js +621 -0
- package/runtime/state.js +26 -7
- package/runtime/utils.js +97 -5
- package/vibe.css +3 -1
package/compiler/src/Cargo.toml
CHANGED
|
@@ -20,7 +20,7 @@ markup5ever = "0.12"
|
|
|
20
20
|
thiserror = "1.0"
|
|
21
21
|
regex = "1.10"
|
|
22
22
|
colored = "2.1"
|
|
23
|
-
|
|
23
|
+
ureq = { version = "2", features = ["tls"] }
|
|
24
24
|
glob = "0.3"
|
|
25
25
|
rquickjs = "0.6"
|
|
26
26
|
swc_common = "=0.40.1"
|
|
@@ -1422,17 +1422,9 @@ impl Compiler {
|
|
|
1422
1422
|
|
|
1423
1423
|
/// Fetch external component without caching (returns raw content)
|
|
1424
1424
|
fn fetch_external_component_raw(&self, url: &str) -> Result<String, String> {
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
if !response.status().is_success() {
|
|
1429
|
-
return Err(format!("HTTP {} - {}", response.status().as_u16(), response.status().canonical_reason().unwrap_or("Unknown")));
|
|
1430
|
-
}
|
|
1431
|
-
match response.text() {
|
|
1432
|
-
Ok(content) => Ok(content),
|
|
1433
|
-
Err(e) => Err(format!("Failed to read response body: {}", e)),
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1425
|
+
match ureq::get(url).call() {
|
|
1426
|
+
Ok(response) => response.into_string().map_err(|e| format!("Failed to read response body: {}", e)),
|
|
1427
|
+
Err(ureq::Error::Status(code, response)) => Err(format!("HTTP {} - {}", code, response.status_text().to_string())),
|
|
1436
1428
|
Err(e) => Err(format!("Failed to fetch: {}", e)),
|
|
1437
1429
|
}
|
|
1438
1430
|
}
|
|
@@ -231,15 +231,15 @@ impl HtmlParser {
|
|
|
231
231
|
replacement = replacement.replace(&prop_binding, &prop_value);
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
-
// Replace <slot> tags
|
|
235
|
-
let
|
|
236
|
-
|
|
234
|
+
// Replace <slot> tags — wrap children in <slot> boundary, or remove if empty
|
|
235
|
+
let slot_wrapped = if slot_content.trim().is_empty() {
|
|
236
|
+
String::new()
|
|
237
237
|
} else {
|
|
238
|
-
|
|
238
|
+
format!("<slot>{}</slot>", slot_content)
|
|
239
239
|
};
|
|
240
|
-
replacement = replacement.replace("<slot></slot>",
|
|
241
|
-
replacement = replacement.replace("<slot/>",
|
|
242
|
-
replacement = replacement.replace("<slot />",
|
|
240
|
+
replacement = replacement.replace("<slot></slot>", &slot_wrapped);
|
|
241
|
+
replacement = replacement.replace("<slot/>", &slot_wrapped);
|
|
242
|
+
replacement = replacement.replace("<slot />", &slot_wrapped);
|
|
243
243
|
|
|
244
244
|
// Keep the wrapper for consistency with runtime (using generic <component> wrapper)
|
|
245
245
|
// No src attribute = wrapper won't be re-processed
|
|
@@ -371,10 +371,10 @@ impl HtmlParser {
|
|
|
371
371
|
replacement = replacement.replace(&prop_binding, prop_value);
|
|
372
372
|
}
|
|
373
373
|
|
|
374
|
-
let
|
|
375
|
-
replacement = replacement.replace("<slot></slot>",
|
|
376
|
-
replacement = replacement.replace("<slot/>",
|
|
377
|
-
replacement = replacement.replace("<slot />",
|
|
374
|
+
let slot_wrapped = if slot_content.trim().is_empty() { String::new() } else { format!("<slot>{}</slot>", slot_content) };
|
|
375
|
+
replacement = replacement.replace("<slot></slot>", &slot_wrapped);
|
|
376
|
+
replacement = replacement.replace("<slot/>", &slot_wrapped);
|
|
377
|
+
replacement = replacement.replace("<slot />", &slot_wrapped);
|
|
378
378
|
|
|
379
379
|
let wrapper = format!("<component>{}</component>", replacement);
|
|
380
380
|
result.replace_range(*start..*end, &wrapper);
|
|
@@ -447,10 +447,10 @@ impl HtmlParser {
|
|
|
447
447
|
replacement = replacement.replace(&prop_binding, prop_value);
|
|
448
448
|
}
|
|
449
449
|
|
|
450
|
-
let
|
|
451
|
-
replacement = replacement.replace("<slot></slot>",
|
|
452
|
-
replacement = replacement.replace("<slot/>",
|
|
453
|
-
replacement = replacement.replace("<slot />",
|
|
450
|
+
let slot_wrapped = if slot_content.trim().is_empty() { String::new() } else { format!("<slot>{}</slot>", slot_content) };
|
|
451
|
+
replacement = replacement.replace("<slot></slot>", &slot_wrapped);
|
|
452
|
+
replacement = replacement.replace("<slot/>", &slot_wrapped);
|
|
453
|
+
replacement = replacement.replace("<slot />", &slot_wrapped);
|
|
454
454
|
|
|
455
455
|
let wrapper = format!("<component>{}</component>", replacement);
|
|
456
456
|
result.replace_range(start..end, &wrapper);
|
package/index.js
CHANGED
|
@@ -5,9 +5,16 @@ import { boot, isBooted, ensureBoot } from './boot.js';
|
|
|
5
5
|
|
|
6
6
|
// Shared instance for queueing listeners before boot
|
|
7
7
|
let vibeInstance = null;
|
|
8
|
+
let resolveInstanceReady = null;
|
|
8
9
|
|
|
9
10
|
const createVibeInstance = () => ({
|
|
10
11
|
_pendingListeners: { afterUpdate: [], afterDomMutation: [], ready: [] },
|
|
12
|
+
// Promise that resolves when the real $.ready resolves post-boot. Lets
|
|
13
|
+
// consumers holding the pre-boot placeholder (e.g. tests awaiting
|
|
14
|
+
// `window.$.ready` before boot has replaced $ with the reactive proxy)
|
|
15
|
+
// wait for readiness without polling. Non-enumerable so it stays out of
|
|
16
|
+
// state snapshots.
|
|
17
|
+
ready: new Promise((resolve) => { resolveInstanceReady = resolve; }),
|
|
11
18
|
on(event, callback) {
|
|
12
19
|
// If booted, delegate to window.$
|
|
13
20
|
if (isBooted() && window.$) {
|
|
@@ -66,4 +73,12 @@ const vibe = (state = {}, config, targetSelector) => {
|
|
|
66
73
|
// Export function to get pending listeners (used by boot.js)
|
|
67
74
|
export const getPendingListeners = () => vibeInstance?._pendingListeners || null;
|
|
68
75
|
|
|
76
|
+
// Forward post-boot $.ready to the pre-boot placeholder's ready promise.
|
|
77
|
+
// Called by boot.js after main() creates the real reactive proxy.
|
|
78
|
+
export const chainInstanceReady = (realReadyPromise) => {
|
|
79
|
+
if (resolveInstanceReady && realReadyPromise) {
|
|
80
|
+
realReadyPromise.then(() => resolveInstanceReady());
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
69
84
|
export default vibe;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ape-egg/vibe",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Runtime-first reactivity with optional compiler",
|
|
6
6
|
"main": "index.js",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"scripts": {
|
|
28
28
|
"test": "echo \"Error: no test specified\" && exit 1"
|
|
29
29
|
},
|
|
30
|
-
"author": "
|
|
30
|
+
"author": "kkortes",
|
|
31
31
|
"license": "ISC",
|
|
32
32
|
"publishConfig": {
|
|
33
33
|
"access": "public"
|
|
@@ -28,6 +28,15 @@
|
|
|
28
28
|
|
|
29
29
|
import { BINDING_REGEX } from './constants.js';
|
|
30
30
|
|
|
31
|
+
// innerHTML serialization encodes <, >, &, ", ' inside attribute values.
|
|
32
|
+
// Decode them back before wrapping @[expr] in ${...} for the template literal.
|
|
33
|
+
const decodeEntities = (s) => s
|
|
34
|
+
.replace(/</g, '<')
|
|
35
|
+
.replace(/>/g, '>')
|
|
36
|
+
.replace(/"/g, '"')
|
|
37
|
+
.replace(/'/g, "'")
|
|
38
|
+
.replace(/&/g, '&');
|
|
39
|
+
|
|
31
40
|
// Reusable template element for HTML parsing
|
|
32
41
|
const parseTemplate = document.createElement('template');
|
|
33
42
|
|
|
@@ -59,20 +68,20 @@ const hasNestedStructures = (tree) => {
|
|
|
59
68
|
export const compileBatchFn = (template, itemAlias, indexAlias, stateKeys) => {
|
|
60
69
|
const templateHtml = template.element.innerHTML.trim();
|
|
61
70
|
const escaped = templateHtml.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
|
|
62
|
-
const code = escaped.replace(BINDING_REGEX, (_, expr) => '${' + expr + '}');
|
|
71
|
+
const code = escaped.replace(BINDING_REGEX, (_, expr) => '${' + decodeEntities(expr) + '}');
|
|
63
72
|
|
|
64
73
|
return new Function(
|
|
65
74
|
'arr',
|
|
66
75
|
...stateKeys,
|
|
67
76
|
`
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
77
|
+
let html = '';
|
|
78
|
+
const len = arr.length;
|
|
79
|
+
for (let ${indexAlias} = 0; ${indexAlias} < len; ${indexAlias}++) {
|
|
80
|
+
const ${itemAlias} = arr[${indexAlias}];
|
|
81
|
+
html += \`${code}\`;
|
|
82
|
+
}
|
|
83
|
+
return html;
|
|
84
|
+
`,
|
|
76
85
|
);
|
|
77
86
|
};
|
|
78
87
|
|
package/runtime/affected.js
CHANGED
|
@@ -6,20 +6,33 @@ import { evalInScope, resolveThisPath } from './utils.js';
|
|
|
6
6
|
// Evaluate conditional expression
|
|
7
7
|
const evaluateCondition = (expression, state, element = null) => !!evalInScope(expression, state, element);
|
|
8
8
|
|
|
9
|
-
// Helper function to check if a match references a specific key
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
// Helper function to check if a match references a specific key.
|
|
10
|
+
// Fast path: exact match or property access (`user.name` matches `user`).
|
|
11
|
+
// Slow path: word-boundary search for complex expressions like `Math.floor(coins / 100)`
|
|
12
|
+
// where the key appears as an identifier anywhere in the expression.
|
|
13
|
+
const isIdentChar = (c) => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c === '_' || c === '$';
|
|
14
|
+
|
|
15
|
+
const matchesKey = (matchStr, key) => {
|
|
16
|
+
if (matchStr === key || matchStr.startsWith(key + '.') || matchStr.startsWith(key + '[')) return true;
|
|
17
|
+
|
|
18
|
+
// Search for key as a standalone identifier (word boundaries on both sides)
|
|
19
|
+
let i = 0;
|
|
20
|
+
while ((i = matchStr.indexOf(key, i)) !== -1) {
|
|
21
|
+
const before = i === 0 ? '' : matchStr[i - 1];
|
|
22
|
+
const after = i + key.length >= matchStr.length ? '' : matchStr[i + key.length];
|
|
23
|
+
if (!isIdentChar(before) && !isIdentChar(after)) return true;
|
|
24
|
+
i += key.length;
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
};
|
|
14
28
|
|
|
15
29
|
const recursive = (tree, state, newState, affected, scopedStateForHydration = null, depth = 0) => {
|
|
16
30
|
// Handle iteration nodes specially
|
|
17
31
|
if (tree.type === 'iteration') {
|
|
18
|
-
|
|
19
|
-
const arrayPath = resolveThisPath(tree.meta.arrayPath, tree.meta.startComment?.parentElement);
|
|
32
|
+
const resolvedExpr = resolveThisPath(tree.meta.arrayPath, tree.meta.startComment?.parentElement);
|
|
20
33
|
|
|
21
|
-
const oldArray = resolvePath(state,
|
|
22
|
-
const newArray = resolvePath(newState,
|
|
34
|
+
const oldArray = evalInScope(resolvedExpr, state, tree.meta.startComment?.parentElement) ?? resolvePath(state, resolvedExpr);
|
|
35
|
+
const newArray = evalInScope(resolvedExpr, newState, tree.meta.startComment?.parentElement) ?? resolvePath(newState, resolvedExpr);
|
|
23
36
|
|
|
24
37
|
// Fast path: reference comparison (arrays are typically replaced, not mutated)
|
|
25
38
|
// This avoids expensive O(n) deepEqual for large arrays
|
|
@@ -35,13 +48,35 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
35
48
|
// Array didn't change, but check for affected elements inside iteration instances
|
|
36
49
|
// (e.g., when tutorialProgress changes, need to update checkmarks in menu items)
|
|
37
50
|
if (tree.runtime.instances) {
|
|
51
|
+
// Compiled iterations have instances without tree/scopedState — the batch function
|
|
52
|
+
// may reference global state keys (e.g., `selectedCategory` in a button binding).
|
|
53
|
+
// If any state key changed, trigger a rebuild so the batch function re-evaluates.
|
|
54
|
+
const hasCompiledInstances = tree.compiled && tree.runtime.instances.length > 0
|
|
55
|
+
&& !tree.runtime.instances[0].tree;
|
|
56
|
+
|
|
57
|
+
if (hasCompiledInstances) {
|
|
58
|
+
const isInitialHydration = state === newState;
|
|
59
|
+
if (!isInitialHydration) {
|
|
60
|
+
const stateKeys = Object.keys(state);
|
|
61
|
+
const hasChangedKey = stateKeys.some(k => state[k] !== newState[k]);
|
|
62
|
+
if (hasChangedKey) {
|
|
63
|
+
affected.push({ type: 'iteration', node: tree, changeType: 'array' });
|
|
64
|
+
return affected;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
38
69
|
for (const instance of tree.runtime.instances) {
|
|
39
70
|
if (instance.tree && instance.scopedState) {
|
|
40
|
-
//
|
|
41
|
-
//
|
|
71
|
+
// Build plain-object snapshots for comparison. scopedState is a live Proxy
|
|
72
|
+
// that reflects current global state — spreading it gives us the local vars
|
|
73
|
+
// (cat, index) plus current global values. We then override globals with the
|
|
74
|
+
// actual old (state) / new (newState) values so binding comparisons can detect
|
|
75
|
+
// which state keys changed.
|
|
76
|
+
const mergedOldState = { ...instance.scopedState, ...state };
|
|
42
77
|
const mergedNewState = { ...instance.scopedState, ...newState };
|
|
43
|
-
// Pass
|
|
44
|
-
recursive(instance.tree,
|
|
78
|
+
// Pass scopedState as scopedStateForHydration so evalInScope can access iteration variables
|
|
79
|
+
recursive(instance.tree, mergedOldState, mergedNewState, affected, instance.scopedState, depth + 1);
|
|
45
80
|
}
|
|
46
81
|
}
|
|
47
82
|
}
|
|
@@ -202,17 +237,20 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
202
237
|
for (const m of nameMatches) {
|
|
203
238
|
// Resolve this.property to componentId.property
|
|
204
239
|
const resolvedInner = resolveThisPath(m.inner, tree.element);
|
|
240
|
+
// HTML lowercases attribute names, so expression may be lowercase while state
|
|
241
|
+
// keys are camelCase. Match case-insensitively by trying both direct and lowercased.
|
|
242
|
+
const matchKey = (key) => matchesKey(resolvedInner, key) || matchesKey(resolvedInner, key.toLowerCase());
|
|
205
243
|
|
|
206
|
-
const noMatch = !shallowState.some(
|
|
244
|
+
const noMatch = !shallowState.some(matchKey);
|
|
207
245
|
|
|
208
246
|
let shouldAffect = false;
|
|
209
247
|
|
|
210
248
|
if (isInitialHydration) {
|
|
211
|
-
const newMatches = shallowNewState.filter(
|
|
249
|
+
const newMatches = shallowNewState.filter(matchKey);
|
|
212
250
|
shouldAffect = noMatch || newMatches.length > 0;
|
|
213
251
|
} else {
|
|
214
252
|
const changedKeys = shallowNewState.filter((key) =>
|
|
215
|
-
|
|
253
|
+
matchKey(key) && state[key] !== newState[key]
|
|
216
254
|
);
|
|
217
255
|
shouldAffect = noMatch || changedKeys.length > 0;
|
|
218
256
|
}
|