@ape-egg/vibe 2.1.10 → 2.1.12

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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.1.12] - 2026-06-22
4
+
5
+ ### Fixed
6
+
7
+ - **Nodes resolving inside slot-projected branch/iteration content were orphaned from the reactive tree** (`runtime/index.js`) — `navigateTree` only walks a node's `children`, but content projected across a `<slot>` boundary (a conditional branch's or an iteration instance's slotted content) is registered in the flat manifest under a path that isn't reachable through `children` — the slot node's children don't include the projected subtree. So when a `<component src>` (or any node) mounted inside such content, `navigateTree` returned null: on add, the resolved subtree never linked into the reactive tree (so it wasn't reactive); on remove, the stale tree entry lingered beside its replacement. A new `findNodeByElement` does an identity search across the *full* tree — plain `children`, conditional branch trees (`runtime.activeInstance.parsedTree`), and iteration instance trees (`runtime.instances`) — and is used as a fallback at both the add and remove sites when path navigation can't reach the parent, so the subtree links in and cleans up regardless of slot/branch/iteration projection.
8
+
9
+ ## [2.1.11] - 2026-06-22
10
+
11
+ ### Fixed
12
+
13
+ - **Compiler 1.9.6 → 1.9.7 — name-binding expressions were lowercased, breaking camelCase identifiers** (`compiler/src/compiler/binding_case.rs` (new), `component_tagger.rs`, `manifest_builder.rs`) — html5ever lowercases attribute *names* per the HTML spec, and a name-binding lives in attribute-name position (`<icon @[selectedEquipProps(uuid).element]>`), so every parse/serialize round-trip lowered its expression (`selectedEquipProps` → `selectedequipprops`, undefined at runtime). Attribute *values* keep their case, so value-bindings were never affected. The new `binding_case` module snapshots the original-cased `@[...]` bindings before the round-trip (keyed by their lowercased form, first-wins on collision) and restores them afterward — applied both to the tagged HTML in `component_tagger` and to every string the manifest builder serializes, including its captured `name_bindings` array. Repro: `tests/compiler/name-binding-case`.
14
+ - **Name-binding paths wrapped in grouping parens by component-prop substitution failed to resolve** (`runtime/utils.js`) — `resolveCaseInsensitivePath` bailed on any `(`, but reusing a component rewrites a prop reference like `props.element` into `(equipmentDetailProps).element`, and the HTML parser lowercases the surrounding name-binding attribute. It now strips grouping parens to recover the plain dotted path and resolves it case-insensitively, still bailing on a genuine function call (`selectedEquipProps(uuid)`) that needs the full evaluator. Covered by `tests/unit/utils.test.js`.
15
+
3
16
  ## [2.1.10] - 2026-06-21
4
17
 
5
18
  ### Fixed
@@ -1599,7 +1599,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1599
1599
 
1600
1600
  [[package]]
1601
1601
  name = "vibe-compiler"
1602
- version = "1.9.6"
1602
+ version = "1.9.7"
1603
1603
  dependencies = [
1604
1604
  "clap",
1605
1605
  "colored",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vibe-compiler"
3
- version = "1.9.6"
3
+ version = "1.9.7"
4
4
  edition = "2021"
5
5
  description = "Vibe framework compiler - compiles Vibe source files into optimized output"
6
6
  authors = ["Kim Korte"]
@@ -0,0 +1,88 @@
1
+ use regex::Regex;
2
+ use std::collections::HashMap;
3
+ use std::sync::OnceLock;
4
+
5
+ /// The canonical `@[...]` binding matcher, shared with manifest_builder /
6
+ /// value_stamper. A binding body is any run of non-bracket chars, optionally
7
+ /// containing a single bracketed group (`items[0]`).
8
+ fn binding_regex() -> &'static Regex {
9
+ static RE: OnceLock<Regex> = OnceLock::new();
10
+ RE.get_or_init(|| Regex::new(r"@\[((?:[^\[\]]|\[[^\]]*\])+)\]").unwrap())
11
+ }
12
+
13
+ /// html5ever lowercases attribute NAMES per the HTML spec. A name-binding lives
14
+ /// in attribute-name position (`<icon @[selectedEquipProps(uuid).element]>`), so
15
+ /// its expression is lowercased by every parse/serialize round-trip — the
16
+ /// identifier `selectedEquipProps` becomes `selectedequipprops`, undefined at
17
+ /// runtime. Attribute VALUES keep their case, so value-bindings are unaffected.
18
+ ///
19
+ /// We can't tell html5ever to preserve case, so we restore it ourselves: snapshot
20
+ /// the original-cased `@[...]` bindings from the input BEFORE the round-trip, then
21
+ /// map them back over the output AFTER. The map is keyed by the lowercased binding
22
+ /// (what the round-trip produces) → original binding.
23
+ ///
24
+ /// First-wins on a lowercase collision (two differently-cased bindings that
25
+ /// lowercase to the same key): rare, and either casing is a defensible restore.
26
+ pub fn capture(input: &str) -> HashMap<String, String> {
27
+ let mut map = HashMap::new();
28
+ for caps in binding_regex().captures_iter(input) {
29
+ let original = caps.get(0).unwrap().as_str();
30
+ let lower = original.to_ascii_lowercase();
31
+ if lower != original {
32
+ map.entry(lower).or_insert_with(|| original.to_string());
33
+ }
34
+ }
35
+ map
36
+ }
37
+
38
+ /// Restore original casing of `@[...]` bindings in `output` using a map built by
39
+ /// [`capture`]. Only bindings whose lowercased form is a known key are rewritten;
40
+ /// everything else is left verbatim, so this is a no-op when nothing was lowered.
41
+ pub fn restore(output: &str, map: &HashMap<String, String>) -> String {
42
+ if map.is_empty() {
43
+ return output.to_string();
44
+ }
45
+ binding_regex()
46
+ .replace_all(output, |caps: &regex::Captures| {
47
+ let matched = caps.get(0).unwrap().as_str();
48
+ match map.get(&matched.to_ascii_lowercase()) {
49
+ Some(original) => original.clone(),
50
+ None => matched.to_string(),
51
+ }
52
+ })
53
+ .to_string()
54
+ }
55
+
56
+ #[cfg(test)]
57
+ mod tests {
58
+ use super::*;
59
+
60
+ #[test]
61
+ fn restores_lowercased_name_binding() {
62
+ // Post-prop-substitution name binding (parens added by substitute_props),
63
+ // as it enters an html5ever round-trip. html5ever lowercases the whole
64
+ // attr name; restore puts the original casing back.
65
+ let input = "<icon @[(selectedEquipProps(uuid)).element]></icon>";
66
+ let map = capture(input);
67
+ let lowered = "<icon @[(selectedequipprops(uuid)).element]></icon>";
68
+ let restored = restore(lowered, &map);
69
+ assert_eq!(restored, input);
70
+ }
71
+
72
+ #[test]
73
+ fn leaves_value_bindings_untouched_when_already_correct() {
74
+ let input = r#"<card tinted="@[selectedEquipProps(uuid).element]"></card>"#;
75
+ let map = capture(input);
76
+ // Value bindings keep case through html5ever, so output already matches.
77
+ let restored = restore(input, &map);
78
+ assert_eq!(restored, input);
79
+ }
80
+
81
+ #[test]
82
+ fn noop_when_no_uppercase() {
83
+ let input = "<icon @[tooltip.props.element]></icon>";
84
+ let map = capture(input);
85
+ assert!(map.is_empty());
86
+ assert_eq!(restore(input, &map), input);
87
+ }
88
+ }
@@ -42,6 +42,11 @@ impl ComponentTagger {
42
42
  html_with_placeholders = html_with_placeholders.replace(content, &placeholder);
43
43
  }
44
44
 
45
+ // Snapshot original-cased `@[...]` bindings before html5ever lowercases
46
+ // any that live in attribute-name position (name bindings); restored after
47
+ // serialization below.
48
+ let binding_cases = crate::compiler::binding_case::capture(&html_with_placeholders);
49
+
45
50
  // Parse HTML (with placeholders instead of actual vibe-dehydrate content)
46
51
  let dom = parse_document(RcDom::default(), Default::default())
47
52
  .from_utf8()
@@ -64,6 +69,9 @@ impl ComponentTagger {
64
69
  let mut modified_html = String::from_utf8(modified_html_bytes)
65
70
  .map_err(|e| format!("Failed to convert HTML to UTF-8: {}", e))?;
66
71
 
72
+ // Restore original casing of name-binding expressions html5ever lowercased.
73
+ modified_html = crate::compiler::binding_case::restore(&modified_html, &binding_cases);
74
+
67
75
  // Restore template content from placeholders
68
76
  for (i, content) in template_placeholders.iter().enumerate() {
69
77
  let placeholder = format!("<!--VIBE_TEMPLATE_PLACEHOLDER_{}-->", i);
@@ -4,12 +4,17 @@ use markup5ever_rcdom::{RcDom, NodeData, Handle};
4
4
  use regex::Regex;
5
5
  use serde::{Serialize, Serializer};
6
6
  use serde_json::Value;
7
+ use std::cell::RefCell;
7
8
  use std::collections::{HashMap, BTreeMap};
8
9
  use crate::compiler::iteration_optimizer::build_iteration_optimizations;
9
10
 
10
11
  pub struct ManifestBuilder {
11
12
  binding_regex: Regex,
12
13
  name_binding_fix_regex: Regex,
14
+ /// Original-cased `@[...]` bindings captured from the pre-parse HTML, keyed by
15
+ /// their lowercased form — used to undo html5ever's attribute-name lowercasing
16
+ /// of name bindings on every string this builder serializes back out.
17
+ binding_cases: RefCell<HashMap<String, String>>,
13
18
  }
14
19
 
15
20
  impl ManifestBuilder {
@@ -17,10 +22,16 @@ impl ManifestBuilder {
17
22
  Self {
18
23
  binding_regex: Regex::new(r"@\[((?:[^\[\]]|\[[^\]]*\])+)\]").unwrap(),
19
24
  name_binding_fix_regex: Regex::new(r#"(@\[[^\]]+\])="+"#).unwrap(),
25
+ binding_cases: RefCell::new(HashMap::new()),
20
26
  }
21
27
  }
22
28
 
23
29
  pub fn build_from_html(&self, html: &str, _state: &Value, iterations_as_is: bool) -> Result<ManifestNode, String> {
30
+ // Snapshot original-cased bindings before html5ever lowercases name
31
+ // bindings (attr-name position). Restored on serialized templates and on
32
+ // the captured name_bindings array.
33
+ *self.binding_cases.borrow_mut() = crate::compiler::binding_case::capture(html);
34
+
24
35
  // Parse HTML
25
36
  let dom = parse_document(RcDom::default(), Default::default())
26
37
  .from_utf8()
@@ -133,7 +144,12 @@ impl ManifestBuilder {
133
144
  while name_bindings.len() <= idx {
134
145
  name_bindings.push(None);
135
146
  }
136
- name_bindings[idx] = Some(attr_name.clone());
147
+ // html5ever lowercased the attr name; restore original casing.
148
+ let restored = crate::compiler::binding_case::restore(
149
+ &attr_name,
150
+ &self.binding_cases.borrow(),
151
+ );
152
+ name_bindings[idx] = Some(restored);
137
153
  }
138
154
  // Check for attribute value bindings
139
155
  else if self.binding_regex.is_match(&attr_value) {
@@ -441,6 +457,10 @@ impl ManifestBuilder {
441
457
  // which is invalid HTML that browsers reject
442
458
  html = self.name_binding_fix_regex.replace_all(&html, "$1").to_string();
443
459
 
460
+ // Restore original casing of any name-binding expressions html5ever
461
+ // lowercased while parsing (attr-name position).
462
+ html = crate::compiler::binding_case::restore(&html, &self.binding_cases.borrow());
463
+
444
464
  html
445
465
  }
446
466
  }
@@ -5,6 +5,7 @@ mod value_stamper;
5
5
  mod iteration_optimizer;
6
6
  mod js_analyzer;
7
7
  mod component_tagger;
8
+ mod binding_case;
8
9
  mod reassignment_analyzer;
9
10
  pub mod watcher;
10
11
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "2.1.10",
3
+ "version": "2.1.12",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",
package/runtime/index.js CHANGED
@@ -81,6 +81,48 @@ const navigateTree = (tree, path) => {
81
81
  return path.split('.').filter(k => k).reduce((node, key) => node?.children?.[key], tree);
82
82
  };
83
83
 
84
+ // Locate the tree node whose `.element` is `target`, searching the full tree:
85
+ // plain `children`, plus conditional branch trees (`runtime.activeInstance`)
86
+ // and iteration instance trees (`runtime.instances`). navigateTree only walks
87
+ // `children`, so it can't reach content projected across a <slot> boundary — a
88
+ // conditional branch's slotted content is registered in the flat manifest by
89
+ // path, but that path isn't navigable through `children` (the slot node's
90
+ // children don't include the projected subtree). When a <component src> resolves
91
+ // inside such content, navigateTree returns null and the resolved subtree would
92
+ // be orphaned from the reactive tree. This identity search recovers the real
93
+ // parent node so the subtree links in regardless of slot/branch projection.
94
+ const findNodeByElement = (tree, target) => {
95
+ if (!target) return null;
96
+ const seen = new Set();
97
+ const walk = (node) => {
98
+ if (!node || typeof node !== 'object' || seen.has(node)) return null;
99
+ seen.add(node);
100
+ if (node.element === target) return node;
101
+ if (node.children) {
102
+ for (const key in node.children) {
103
+ const hit = walk(node.children[key]);
104
+ if (hit) return hit;
105
+ }
106
+ }
107
+ const branchTree = node.runtime?.activeInstance?.parsedTree;
108
+ if (branchTree) {
109
+ const hit = walk(branchTree);
110
+ if (hit) return hit;
111
+ }
112
+ const instances = node.runtime?.instances;
113
+ if (instances) {
114
+ for (let i = 0; i < instances.length; i++) {
115
+ if (instances[i]?.tree) {
116
+ const hit = walk(instances[i].tree);
117
+ if (hit) return hit;
118
+ }
119
+ }
120
+ }
121
+ return null;
122
+ };
123
+ return walk(tree);
124
+ };
125
+
84
126
  // Get or create a node in the tree at the given path
85
127
  const ensureNode = (tree, path) => {
86
128
  const keys = path.split('.').filter(k => k);
@@ -853,7 +895,12 @@ const main = (s, config = {}, stringSelector = '') => {
853
895
  const name = dotPath.pop();
854
896
  const parentDotAnnotation = dotPath.join('.');
855
897
 
856
- const picked = navigateTree(parsedTree, parentDotAnnotation);
898
+ // Identity fallback mirrors the added-node path: a parent inside
899
+ // slot-projected branch content isn't reachable via navigateTree's
900
+ // `children` walk, so resolve it by element identity instead — otherwise
901
+ // the removed node's stale tree entry lingers alongside its replacement.
902
+ const picked =
903
+ navigateTree(parsedTree, parentDotAnnotation) || findNodeByElement(parsedTree, target);
857
904
 
858
905
  // If we can't navigate to the parent, skip
859
906
  if (!picked || !picked.element) return;
@@ -912,7 +959,12 @@ const main = (s, config = {}, stringSelector = '') => {
912
959
  // registration since there's no tree branch to attach to.)
913
960
  if (entry) {
914
961
  const [dotAnnotation] = entry;
915
- const picked = navigateTree(parsedTree, dotAnnotation);
962
+ // navigateTree walks `children` only; when the parent lives in a
963
+ // conditional branch's slot-projected content its manifest path isn't
964
+ // navigable that way (see findNodeByElement). Fall back to an identity
965
+ // search so the resolved subtree still links into the reactive tree.
966
+ const picked =
967
+ navigateTree(parsedTree, dotAnnotation) || findNodeByElement(parsedTree, target);
916
968
 
917
969
  if (picked) {
918
970
  if (!picked.element) {
package/runtime/utils.js CHANGED
@@ -221,7 +221,16 @@ export const evalInScope = (expr, state, element = null) => {
221
221
  // `fx`. Bails on bracket/call expressions because those need a real
222
222
  // evaluator (and `evalInScope` already handled them).
223
223
  export const resolveCaseInsensitivePath = (state, path) => {
224
- if (path.includes('[') || path.includes('(')) return undefined;
224
+ if (path.includes('[')) return undefined;
225
+ // Component prop substitution wraps the source in grouping parens, e.g.
226
+ // `props.element` inside a reused component becomes `(equipmentDetailProps).element`.
227
+ // The HTML parser lowercases name-binding attribute names, so strip the grouping
228
+ // parens to recover a plain dotted path; bail if anything but identifiers + dots
229
+ // survives (a real function call like `selectedEquipProps(uuid)` can't be recovered).
230
+ if (path.includes('(')) {
231
+ path = path.replace(/[()]/g, '');
232
+ if (!/^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/.test(path)) return undefined;
233
+ }
225
234
  const segments = path.split('.');
226
235
  let current = state;
227
236
  for (const seg of segments) {