@ape-egg/vibe 2.1.11 → 2.1.13

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.1.13] - 2026-06-22
4
+
5
+ ### Fixed
6
+
7
+ - **Compiler 1.9.7 → 1.9.8 — name-bindings whose expression contains whitespace were torn apart by the HTML parser** (`compiler/src/compiler/name_binding_protect.rs` (new), `compile.rs`, `manifest_builder.rs`, `iteration_optimizer.rs`; `runtime/parse.js`, `iterate.js`, `hydrate.js`) — a name-binding sits in attribute-*name* position (`<icon @[element]>`), and HTML parsers split an attribute token on whitespace. So once a prop inlined an expression that has spaces (`@[EQUIPMENT(item, true).element]`), html5ever (and the browser) tore it into broken attributes and dropped the binding from the manifest — the element silently kept its fallback in compiled mode while non-compiled mode worked. The compiler now relocates every whitespace-bearing name-binding into a single verbatim value attribute `data-vibe-namebind="@[expr]…"` *before* any parse round-trip (value attributes survive byte-for-byte); the manifest builder and iteration optimizer read the expression back out of it, and the runtime restores the `@[expr]` name-binding form (`parse.js`, `iterate.js`) and strips the transport attribute on hydrate (`hydrate.js`). Whitespace-free name-bindings are untouched. Tests: `tests/compiler/name-binding-whitespace`, `tests/e2e/name-binding-prop`, `name-bindings`.
8
+ - **Compiler 1.9.7 → 1.9.8 — `--minify` deleted significant whitespace between inline elements** (`compiler/src/compiler/compile.rs`) — minification collapsed `>\s+<` to `><`, removing whitespace between tags. For inline content that space is significant: the browser (and the non-compiled runtime) render `<em>a</em> <em>b</em>` with a space, so a status chip glued onto its following word (`💠Concussion`) in compiled+minified mode. Minify now collapses inter-element whitespace to a *single space* rather than deleting it — matching both the browser's own collapsing and the non-minified output; where the space is insignificant (between block/table/list/head elements) the parser discards it anyway. Repro: `tests/compiler/minify-significant-whitespace`.
9
+ - **Name-bindings inside an iteration couldn't resolve props stashed in the iter-props global** (`runtime/utils.js`) — `resolveCaseInsensitivePath` only walked the diff state, but a name-binding inside an `<!-- each -->` reads its props from a global stash (`window.__vibeiterprops._p0.statusKey`), so a state-only walk never reached it and the camelCase leaf the parser lowercased stayed unresolved (the status-chip gray-icon parity bug — resolved in compiled mode, not runtime). It now resolves the path's root against the same scope and order `evalInScope` uses — the diff state including scoped loop aliases (via `ownKeysOf`), then globals — so iter-prop stashes and scoped aliases resolve case-insensitively. Covered by `tests/unit/utils.test.js`.
10
+
11
+ ## [2.1.12] - 2026-06-22
12
+
13
+ ### Fixed
14
+
15
+ - **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.
16
+
3
17
  ## [2.1.11] - 2026-06-22
4
18
 
5
19
  ### Fixed
@@ -1599,7 +1599,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1599
1599
 
1600
1600
  [[package]]
1601
1601
  name = "vibe-compiler"
1602
- version = "1.9.7"
1602
+ version = "1.9.8"
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.7"
3
+ version = "1.9.8"
4
4
  edition = "2021"
5
5
  description = "Vibe framework compiler - compiles Vibe source files into optimized output"
6
6
  authors = ["Kim Korte"]
@@ -968,8 +968,12 @@ impl Compiler {
968
968
  use crate::compiler::component_tagger::ComponentTagger;
969
969
  use crate::compiler::value_stamper::ValueStamper;
970
970
 
971
+ // Relocate whitespace-bearing name-bindings into a value-attribute BEFORE any
972
+ // html5ever round-trip splits them on their spaces (see name_binding_protect).
973
+ let protected = crate::compiler::name_binding_protect::protect(html);
974
+
971
975
  // Tag components with deterministic IDs and structure state
972
- let tagged = ComponentTagger::tag_components(html, &source_root.to_path_buf())?;
976
+ let tagged = ComponentTagger::tag_components(&protected, &source_root.to_path_buf())?;
973
977
  let html = &tagged.html; // Use modified HTML with data-vibe-component-id attributes
974
978
  let state = tagged.state;
975
979
 
@@ -2181,9 +2185,17 @@ fn minify_html(html: &str) -> String {
2181
2185
  }
2182
2186
  }
2183
2187
 
2188
+ // Collapse whitespace between tags to a SINGLE space — never delete it.
2189
+ // Inter-element whitespace is significant for inline content: the browser
2190
+ // (and the non-compiled runtime) render `<em>a</em> <em>b</em>` with a space,
2191
+ // so dropping it (`><`) glued words onto preceding inline elements (the
2192
+ // status-chip "💠Concussion" parity bug). One space matches both the browser's
2193
+ // own collapsing and the non-minified/runtime output; where the space is
2194
+ // insignificant (between block/table/list/head elements) the parser discards
2195
+ // it anyway, exactly as it does for the non-minified newline.
2184
2196
  let result = regex::Regex::new(r">\s+<")
2185
2197
  .unwrap()
2186
- .replace_all(&result, "><")
2198
+ .replace_all(&result, "> <")
2187
2199
  .to_string();
2188
2200
 
2189
2201
  result.trim().to_string()
@@ -157,6 +157,28 @@ const VALUE_ATTRS: &[&str] = &[
157
157
 
158
158
  /// Whether an attribute keeps its string value (vs boolean coercion).
159
159
  /// Mirrors isValueStyleAttr in runtime/iterate.js.
160
+ /// Turn ` data-vibe-namebind="@[A]@[B]"` back into ` @[A]="" @[B]=""` so each relocated
161
+ /// binding is rendered as a name-binding (` resolvedName=""`) rather than having its
162
+ /// resolved value written into the transport attribute. See name_binding_protect.
163
+ fn restore_relocated_name_bindings(template: &str) -> String {
164
+ let wrapper = Regex::new(
165
+ r#"\s+data-vibe-namebind="((?:@\[(?:[^\[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+\])+)""#,
166
+ )
167
+ .unwrap();
168
+ let one = Regex::new(r#"@\[(?:[^\[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+\]"#).unwrap();
169
+ wrapper
170
+ .replace_all(template, |caps: &regex::Captures| {
171
+ let mut s = String::new();
172
+ for m in one.find_iter(&caps[1]) {
173
+ s.push(' ');
174
+ s.push_str(m.as_str());
175
+ s.push_str("=\"\"");
176
+ }
177
+ s
178
+ })
179
+ .to_string()
180
+ }
181
+
160
182
  fn is_value_style_attr(name: &str) -> bool {
161
183
  VALUE_ATTRS.contains(&name)
162
184
  || name.starts_with("data-")
@@ -208,6 +230,12 @@ fn compile_template_to_batch_fn(
208
230
  /// Anything the runtime clone+hydrate path renders, this output has to render
209
231
  /// identically — it is the compiled twin of compileBatchFn in runtime/iterate.js.
210
232
  fn emit_template_literal(template: &str) -> String {
233
+ // Restore name-bindings the compiler relocated into `data-vibe-namebind` (their
234
+ // expression has whitespace, so it could not be an HTML attribute name) back to the
235
+ // `@[expr]=""` form the binding pass below renders as ` resolvedName=""`. The compiled
236
+ // twin of the same step in runtime/iterate.js compileBatchFn.
237
+ let template = &restore_relocated_name_bindings(template);
238
+
211
239
  let each_re = Regex::new(r"<!--\s*each\s+([^\s]+)\s+as\s+([^\s,]+)(?:\s*,\s*([^\s]+))?\s*-->").unwrap();
212
240
  let end_re = Regex::new(r"<!--\s*/each\s*-->").unwrap();
213
241
  let attr_binding_re = Regex::new(r#"(\s)([\w-]+)="@\[([^\]]+)\]""#).unwrap();
@@ -138,8 +138,18 @@ impl ManifestBuilder {
138
138
  let attr_name = attr.name.local.to_string();
139
139
  let attr_value = attr.value.to_string();
140
140
 
141
+ // A relocated name-binding: its expression(s) live verbatim in the
142
+ // VALUE of data-vibe-namebind because whitespace barred them from
143
+ // attribute-name position. Treat the value as the name-binding — no
144
+ // case restoration needed (value attrs keep their case).
145
+ if attr_name == crate::compiler::name_binding_protect::NAME_BIND_ATTR {
146
+ while name_bindings.len() <= idx {
147
+ name_bindings.push(None);
148
+ }
149
+ name_bindings[idx] = Some(attr_value);
150
+ }
141
151
  // Check for name bindings (binding in attribute NAME)
142
- if self.binding_regex.is_match(&attr_name) {
152
+ else if self.binding_regex.is_match(&attr_name) {
143
153
  // Extend vec to include this index
144
154
  while name_bindings.len() <= idx {
145
155
  name_bindings.push(None);
@@ -6,6 +6,7 @@ mod iteration_optimizer;
6
6
  mod js_analyzer;
7
7
  mod component_tagger;
8
8
  mod binding_case;
9
+ mod name_binding_protect;
9
10
  mod reassignment_analyzer;
10
11
  pub mod watcher;
11
12
 
@@ -0,0 +1,207 @@
1
+ use regex::Regex;
2
+ use std::sync::OnceLock;
3
+
4
+ /// The attribute a relocated name-binding is parked in. Mirrors the runtime
5
+ /// constant of the same name (runtime/constants.js) — the manifest builder reads
6
+ /// the binding back out of it, and the runtime consumes + strips it on hydrate.
7
+ pub const NAME_BIND_ATTR: &str = "data-vibe-namebind";
8
+
9
+ /// A name-binding lives in attribute-NAME position: `<icon @[expr]>`. HTML parsers
10
+ /// split an attribute token on whitespace, so once a prop is inlined into one and the
11
+ /// expression gains spaces — `<icon @[element]>` becoming `@[EQUIPMENT(item, true).element]`
12
+ /// — html5ever (and the browser) tear it into broken attributes and the binding is lost.
13
+ ///
14
+ /// We relocate every whitespace-bearing name-binding into a single verbatim
15
+ /// value-attribute `data-vibe-namebind="@[expr]@[expr2]…"`. Value attributes are kept
16
+ /// byte-for-byte through parsing, so the manifest builder recovers the exact expression
17
+ /// and the runtime evaluates it through the same path it uses in non-compiled mode —
18
+ /// identical behaviour, no expression rewriting. Whitespace-free name-bindings already
19
+ /// survive as-is and are left untouched.
20
+ pub fn protect(html: &str) -> String {
21
+ // <script>/<style> bodies are raw text — a `<` inside them must not be read as a
22
+ // tag. Park them behind comment placeholders for the tag scan, restore after.
23
+ let (masked, raw_blocks) = mask_raw_text(html);
24
+
25
+ let out = tag_open_regex()
26
+ .replace_all(&masked, |caps: &regex::Captures| {
27
+ let name = &caps[1];
28
+ let attrs = &caps[2];
29
+ let (kept, relocated) = relocate_unsafe(attrs);
30
+ if relocated.is_empty() {
31
+ return caps[0].to_string();
32
+ }
33
+ // Preserve a self-closing slash as the final token.
34
+ let kept_end_trimmed = kept.trim_end();
35
+ let (body, self_close) = match kept_end_trimmed.strip_suffix('/') {
36
+ Some(b) => (b.trim_end(), " /"),
37
+ None => (kept_end_trimmed, ""),
38
+ };
39
+ format!(
40
+ "<{}{} {}=\"{}\"{}>",
41
+ name, body, NAME_BIND_ATTR, relocated, self_close
42
+ )
43
+ })
44
+ .to_string();
45
+
46
+ restore_raw_text(&out, &raw_blocks)
47
+ }
48
+
49
+ /// Matches an opening tag and captures (name, attribute-section). Quoted values may
50
+ /// hold anything but their own quote; bare attribute chars are anything but `<>"'`.
51
+ fn tag_open_regex() -> &'static Regex {
52
+ static RE: OnceLock<Regex> = OnceLock::new();
53
+ RE.get_or_init(|| {
54
+ Regex::new(r#"<([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^<>"'])*)>"#).unwrap()
55
+ })
56
+ }
57
+
58
+ /// Walk a tag's attribute section, moving each whitespace-bearing name-binding out of
59
+ /// the kept attributes and into the concatenated `relocated` string. Quoted attribute
60
+ /// values (and string literals inside an expression) are skipped so value-bindings and
61
+ /// `@[fn('a b')]`-style literals are never mistaken for a name-binding.
62
+ fn relocate_unsafe(attrs: &str) -> (String, String) {
63
+ let bytes = attrs.as_bytes();
64
+ let n = bytes.len();
65
+ let mut kept = String::with_capacity(n);
66
+ let mut relocated = String::new();
67
+ let mut i = 0;
68
+ let mut seg = 0;
69
+
70
+ while i < n {
71
+ let b = bytes[i];
72
+
73
+ // Skip a quoted attribute value verbatim (it may legally contain `@[…]`).
74
+ if b == b'"' || b == b'\'' {
75
+ i += 1;
76
+ while i < n && bytes[i] != b {
77
+ i += 1;
78
+ }
79
+ if i < n {
80
+ i += 1; // consume closing quote
81
+ }
82
+ continue;
83
+ }
84
+
85
+ // A bare name-binding in attribute-name position.
86
+ if b == b'@' && i + 1 < n && bytes[i + 1] == b'[' {
87
+ kept.push_str(&attrs[seg..i]);
88
+ let start = i;
89
+ i += 2;
90
+ let mut depth = 1;
91
+ while i < n && depth > 0 {
92
+ let c = bytes[i];
93
+ if c == b'\'' || c == b'"' {
94
+ i += 1;
95
+ while i < n && bytes[i] != c {
96
+ i += 1;
97
+ }
98
+ if i < n {
99
+ i += 1;
100
+ }
101
+ continue;
102
+ }
103
+ match c {
104
+ b'[' => depth += 1,
105
+ b']' => depth -= 1,
106
+ _ => {}
107
+ }
108
+ i += 1;
109
+ }
110
+ let binding = &attrs[start..i];
111
+ let inner = &binding[2..binding.len() - 1];
112
+ if inner.bytes().any(|x| x.is_ascii_whitespace()) {
113
+ relocated.push_str(binding);
114
+ } else {
115
+ kept.push_str(binding);
116
+ }
117
+ seg = i;
118
+ continue;
119
+ }
120
+
121
+ i += 1;
122
+ }
123
+ kept.push_str(&attrs[seg..]);
124
+ (kept, relocated)
125
+ }
126
+
127
+ fn raw_text_regex() -> &'static Regex {
128
+ static RE: OnceLock<Regex> = OnceLock::new();
129
+ RE.get_or_init(|| Regex::new(r"(?is)<(script|style)\b[^>]*>.*?</(?:script|style)>").unwrap())
130
+ }
131
+
132
+ fn mask_raw_text(html: &str) -> (String, Vec<String>) {
133
+ let mut blocks = Vec::new();
134
+ let mut out = String::with_capacity(html.len());
135
+ let mut last = 0;
136
+ for mat in raw_text_regex().find_iter(html) {
137
+ out.push_str(&html[last..mat.start()]);
138
+ out.push_str(&format!("<!--VIBE_RAWTEXT_{}-->", blocks.len()));
139
+ blocks.push(html[mat.start()..mat.end()].to_string());
140
+ last = mat.end();
141
+ }
142
+ out.push_str(&html[last..]);
143
+ (out, blocks)
144
+ }
145
+
146
+ fn restore_raw_text(html: &str, blocks: &[String]) -> String {
147
+ let mut out = html.to_string();
148
+ for (i, block) in blocks.iter().enumerate() {
149
+ out = out.replace(&format!("<!--VIBE_RAWTEXT_{}-->", i), block);
150
+ }
151
+ out
152
+ }
153
+
154
+ #[cfg(test)]
155
+ mod tests {
156
+ use super::*;
157
+
158
+ #[test]
159
+ fn relocates_whitespace_name_binding() {
160
+ let html = r#"<icon @[EQUIPMENT(item, true).element]></icon>"#;
161
+ let out = protect(html);
162
+ assert!(
163
+ out.contains(r#"data-vibe-namebind="@[EQUIPMENT(item, true).element]""#),
164
+ "got: {}",
165
+ out
166
+ );
167
+ // The raw whitespace binding must no longer sit in attribute-name position.
168
+ assert!(!out.contains("@[EQUIPMENT(item, true).element]>"));
169
+ }
170
+
171
+ #[test]
172
+ fn leaves_whitespace_free_name_binding_untouched() {
173
+ let html = r#"<icon @[(selectedEquipProps(uuid)).element]></icon>"#;
174
+ assert_eq!(protect(html), html);
175
+ }
176
+
177
+ #[test]
178
+ fn leaves_value_binding_untouched() {
179
+ let html = r#"<card tinted="@[fn(a, b).element]"></card>"#;
180
+ assert_eq!(protect(html), html);
181
+ }
182
+
183
+ #[test]
184
+ fn leaves_text_binding_untouched() {
185
+ let html = r#"<label>@[fn(a, b)]</label>"#;
186
+ assert_eq!(protect(html), html);
187
+ }
188
+
189
+ #[test]
190
+ fn ignores_at_bracket_inside_script() {
191
+ let html = r#"<script>const x = "@[fn(a, b)]";</script><icon @[fn(a, b)]></icon>"#;
192
+ let out = protect(html);
193
+ assert!(out.contains(r#"const x = "@[fn(a, b)]";"#), "script mangled: {}", out);
194
+ assert!(out.contains(r#"data-vibe-namebind="@[fn(a, b)]""#), "got: {}", out);
195
+ }
196
+
197
+ #[test]
198
+ fn relocates_multiple_on_one_element() {
199
+ let html = r#"<el @[fn(a, b)] @[gn(c, d)]></el>"#;
200
+ let out = protect(html);
201
+ assert!(
202
+ out.contains(r#"data-vibe-namebind="@[fn(a, b)]@[gn(c, d)]""#),
203
+ "got: {}",
204
+ out
205
+ );
206
+ }
207
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "2.1.11",
3
+ "version": "2.1.13",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",
@@ -65,6 +65,13 @@ export default (affected, state, manifest = {}, oldState = {}) => {
65
65
  } else {
66
66
  element._vibeNameBindings.delete(nameBinding);
67
67
  }
68
+
69
+ // A binding relocated into `data-vibe-namebind` (clone path) has served its
70
+ // transport purpose once the real attribute is set — drop it so the rendered
71
+ // DOM matches the batch path, which never emits it.
72
+ if (element.hasAttribute('data-vibe-namebind')) {
73
+ element.removeAttribute('data-vibe-namebind');
74
+ }
68
75
  } catch (e) {
69
76
  console.error('Error hydrating name binding:', e);
70
77
  }
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) {
@@ -193,6 +193,17 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
193
193
 
194
194
  let templateHtml = tplClone.innerHTML.trim();
195
195
 
196
+ // A name-binding the compiler relocated into `data-vibe-namebind` (its expression has
197
+ // whitespace, so it can't be an HTML attribute name) — restore the `@[expr]=""` form
198
+ // the name-binding pass below understands. Safe in this template STRING: only the
199
+ // resolved value (no whitespace) ever reaches innerHTML. Mirrors the clone path, which
200
+ // reads the same binding from the manifest's nameBindings.
201
+ templateHtml = templateHtml.replace(
202
+ /\sdata-vibe-namebind="((?:@\[(?:[^[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+\])+)"/g,
203
+ (_, bindings) =>
204
+ bindings.replace(/@\[(?:[^[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+\]/g, (b) => ' ' + b + '=""'),
205
+ );
206
+
196
207
  // Rewrite `this.X` only inside @[…] expressions so literal occurrences in
197
208
  // text content (e.g. a code example explaining `this.foo`) aren't mangled.
198
209
  // Mirrors evalInScope's behavior in the clone path.
package/runtime/parse.js CHANGED
@@ -42,6 +42,14 @@ const captureAttributeBindings = (element, aliasSet) => {
42
42
  const attr = element.attributes[j];
43
43
  BINDING_REGEX.lastIndex = 0;
44
44
 
45
+ // A name-binding the compiler relocated into data-vibe-namebind because its
46
+ // expression has whitespace (barred from attribute-name position): the value
47
+ // holds the verbatim @[expr](s). Treat it as a name binding, not a value binding.
48
+ if (attr.name === 'data-vibe-namebind') {
49
+ nameBindings.push(attr.value);
50
+ continue;
51
+ }
52
+
45
53
  // Attribute name itself contains a binding (e.g. <icon @[section.icon]>).
46
54
  if (BINDING_REGEX.test(attr.name)) {
47
55
  nameBindings.push(attr.name);
package/runtime/utils.js CHANGED
@@ -231,8 +231,31 @@ export const resolveCaseInsensitivePath = (state, path) => {
231
231
  path = path.replace(/[()]/g, '');
232
232
  if (!/^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/.test(path)) return undefined;
233
233
  }
234
- const segments = path.split('.');
235
- let current = state;
234
+ let segments = path.split('.');
235
+
236
+ // Resolve the root against the SAME scope evalInScope reaches, in its order:
237
+ // the diff state (including scoped loop aliases — see ownKeysOf below), then
238
+ // globals. A name-binding inside an iteration reads its props from a global
239
+ // stash — `window.__vibeiterprops._p0.statusKey` — so a state-only walk can
240
+ // never reach it, and the camelCase leaf the parser lowercased stays lost
241
+ // (the status-chip gray-icon parity bug: resolved in compiled, not runtime).
242
+ const root = segments[0];
243
+ const rootInState =
244
+ state != null &&
245
+ (Reflect.has(Object(state), root) ||
246
+ ownKeysOf(Object(state)).some((k) => k.toLowerCase() === root.toLowerCase()));
247
+ let current;
248
+ if (rootInState) {
249
+ current = state;
250
+ } else if (root === 'window' || root === 'globalThis') {
251
+ current = globalThis;
252
+ segments = segments.slice(1);
253
+ } else if (typeof globalThis !== 'undefined' && Reflect.has(globalThis, root)) {
254
+ current = globalThis;
255
+ } else {
256
+ return undefined;
257
+ }
258
+
236
259
  for (const seg of segments) {
237
260
  if (current == null) return undefined;
238
261
  // Direct first — handles proxies (scoped iteration state) and plain objects.
@@ -240,8 +263,11 @@ export const resolveCaseInsensitivePath = (state, path) => {
240
263
  current = current[seg];
241
264
  continue;
242
265
  }
243
- if (typeof current !== 'object') return undefined;
244
- const ci = Object.keys(current).find((k) => k.toLowerCase() === seg.toLowerCase());
266
+ if (typeof current !== 'object' && typeof current !== 'function') return undefined;
267
+ // Read keys from the SAME source evalInScope does (ownKeysOf), so a scoped
268
+ // loop alias — present in the proxy's precomputed key list but hidden from
269
+ // Object.keys/Reflect.has — resolves here too.
270
+ const ci = ownKeysOf(current).find((k) => k.toLowerCase() === seg.toLowerCase());
245
271
  if (!ci) return undefined;
246
272
  current = current[ci];
247
273
  }