@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.
@@ -20,7 +20,7 @@ markup5ever = "0.12"
20
20
  thiserror = "1.0"
21
21
  regex = "1.10"
22
22
  colored = "2.1"
23
- reqwest = { version = "0.11", features = ["blocking", "rustls-tls"], default-features = false }
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
- // Fetch from URL
1426
- match reqwest::blocking::get(url) {
1427
- Ok(response) => {
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 with content, or remove if empty/missing
235
- let slot_replacement = if slot_content.trim().is_empty() {
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
- &slot_content
238
+ format!("<slot>{}</slot>", slot_content)
239
239
  };
240
- replacement = replacement.replace("<slot></slot>", slot_replacement);
241
- replacement = replacement.replace("<slot/>", slot_replacement);
242
- replacement = replacement.replace("<slot />", slot_replacement);
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 slot_replacement = if slot_content.trim().is_empty() { "" } else { slot_content.as_str() };
375
- replacement = replacement.replace("<slot></slot>", slot_replacement);
376
- replacement = replacement.replace("<slot/>", slot_replacement);
377
- replacement = replacement.replace("<slot />", slot_replacement);
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 slot_replacement = if slot_content.trim().is_empty() { "" } else { slot_content.as_str() };
451
- replacement = replacement.replace("<slot></slot>", slot_replacement);
452
- replacement = replacement.replace("<slot/>", slot_replacement);
453
- replacement = replacement.replace("<slot />", slot_replacement);
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.7.2",
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": "Kim Korte",
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(/&lt;/g, '<')
35
+ .replace(/&gt;/g, '>')
36
+ .replace(/&quot;/g, '"')
37
+ .replace(/&#39;/g, "'")
38
+ .replace(/&amp;/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
- let html = '';
69
- const len = arr.length;
70
- for (let ${indexAlias} = 0; ${indexAlias} < len; ${indexAlias}++) {
71
- const ${itemAlias} = arr[${indexAlias}];
72
- html += \`${code}\`;
73
- }
74
- return html;
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
 
@@ -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
- const matchesKey = (matchStr, key) =>
11
- matchStr === key ||
12
- matchStr.startsWith(key + '.') ||
13
- matchStr.startsWith(key + '[');
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
- // Handle this.property for component-scoped arrays
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, arrayPath);
22
- const newArray = resolvePath(newState, arrayPath);
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
- // Use the instance's scoped state (includes item, index, etc.)
41
- // Merge newState into scopedState to get updated global values
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 the Proxy as scopedState so evalInScope can access iteration variables
44
- recursive(instance.tree, instance.scopedState, mergedNewState, affected, instance.scopedState, depth + 1);
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((key) => matchesKey(resolvedInner, key));
244
+ const noMatch = !shallowState.some(matchKey);
207
245
 
208
246
  let shouldAffect = false;
209
247
 
210
248
  if (isInitialHydration) {
211
- const newMatches = shallowNewState.filter((key) => matchesKey(resolvedInner, key));
249
+ const newMatches = shallowNewState.filter(matchKey);
212
250
  shouldAffect = noMatch || newMatches.length > 0;
213
251
  } else {
214
252
  const changedKeys = shallowNewState.filter((key) =>
215
- matchesKey(resolvedInner, key) && state[key] !== newState[key]
253
+ matchKey(key) && state[key] !== newState[key]
216
254
  );
217
255
  shouldAffect = noMatch || changedKeys.length > 0;
218
256
  }