@ape-egg/vibe 2.1.12 → 2.1.14
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 +14 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +1 -1
- package/compiler/src/Cargo.toml +1 -1
- package/compiler/src/compiler/compile.rs +14 -2
- package/compiler/src/compiler/iteration_optimizer.rs +28 -0
- package/compiler/src/compiler/manifest_builder.rs +11 -1
- package/compiler/src/compiler/mod.rs +1 -0
- package/compiler/src/compiler/name_binding_protect.rs +207 -0
- package/compiler/src/parser/html.rs +7 -1
- package/package.json +1 -1
- package/runtime/component.js +9 -2
- package/runtime/hydrate.js +7 -0
- package/runtime/iterate.js +11 -0
- package/runtime/parse.js +8 -0
- package/runtime/utils.js +30 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.1.14] - 2026-06-22
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **Compiler 1.9.8 → 1.9.9 — a component prop referenced *bare* inside an event handler threw at fire time** (`compiler/src/parser/html.rs`, `runtime/component.js`) — prop substitution into `on*` handlers only rewrote the `$.prop` form, so a bare prop reference (`onclick="pick(item)"` where `item` is a prop) was left untouched. Because a native inline handler runs in *global* scope when it fires, that bare identifier resolved to an undefined global and threw a `ReferenceError`. Both the compiler's `substitute_props` and the runtime's `renderPropsAndSlot` now also substitute the bare prop identifier — mirroring the existing `@[...]` binding pass — so the handler receives the live prop with object identity preserved: a loop-alias path (`row.sig`) becomes `(row.sig)`, which `parse.js` rewrites to the scoped accessor, and a literal prop (`limit="5"`) inlines as the numeric `5`. The bare pass skips the `$.prop` form (lookbehind guard) so the two rewrites don't collide. Tests: `e2e-runtime/prop-in-handler.html` + `components/test-prop-handler.html`, exercised by `tests/e2e/component-props.spec.js`.
|
|
8
|
+
|
|
9
|
+
## [2.1.13] - 2026-06-22
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **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`.
|
|
14
|
+
- **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`.
|
|
15
|
+
- **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`.
|
|
16
|
+
|
|
3
17
|
## [2.1.12] - 2026-06-22
|
|
4
18
|
|
|
5
19
|
### Fixed
|
|
Binary file
|
|
Binary file
|
package/compiler/src/Cargo.lock
CHANGED
package/compiler/src/Cargo.toml
CHANGED
|
@@ -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(
|
|
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: ®ex::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);
|
|
@@ -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: ®ex::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
|
+
}
|
|
@@ -690,7 +690,13 @@ fn substitute_props(template: &str, props: &[(String, String)]) -> String {
|
|
|
690
690
|
.replace_all(&html, |c: ®ex::Captures| {
|
|
691
691
|
let ev = c.get(1).unwrap().as_str();
|
|
692
692
|
let body = c.get(2).unwrap().as_str();
|
|
693
|
-
|
|
693
|
+
// First the `$.prop` form, then the bare prop identifier — mirroring
|
|
694
|
+
// the runtime's two-step rewrite so `onclick="pick(item)"` resolves
|
|
695
|
+
// the live prop instead of throwing ReferenceError in global scope.
|
|
696
|
+
// The bare pass skips `$.prop` (its `.`-lookbehind guard) so the two
|
|
697
|
+
// don't collide.
|
|
698
|
+
let after_state = substitute_state_ref(body, prop_name, &state_repl);
|
|
699
|
+
let rewritten = substitute_identifier(&after_state, prop_name, &ident_repl);
|
|
694
700
|
if rewritten == body {
|
|
695
701
|
c.get(0).unwrap().as_str().to_string()
|
|
696
702
|
} else {
|
package/package.json
CHANGED
package/runtime/component.js
CHANGED
|
@@ -369,7 +369,12 @@ const renderPropsAndSlot = (temp, props, slotHtml) => {
|
|
|
369
369
|
);
|
|
370
370
|
const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'gi');
|
|
371
371
|
transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
|
|
372
|
-
|
|
372
|
+
// Substitute the bare prop identifier too (not just the `$.prop` form),
|
|
373
|
+
// mirroring the @[...] binding pass above — so `onclick="pick(item)"`
|
|
374
|
+
// resolves the live prop, not a global that throws ReferenceError. A
|
|
375
|
+
// loop-alias path (`row.sig`) becomes `(row.sig)` here; parse.js then
|
|
376
|
+
// rewrites the alias to `$scope(this,'row')`.
|
|
377
|
+
const rewritten = substituteInExpr(body.replace(stateRegex, `$.${path}`), `(${path})`);
|
|
373
378
|
return rewritten === body ? match : `on${evName}="${rewritten}"`;
|
|
374
379
|
});
|
|
375
380
|
} else {
|
|
@@ -395,7 +400,9 @@ const renderPropsAndSlot = (temp, props, slotHtml) => {
|
|
|
395
400
|
);
|
|
396
401
|
const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'gi');
|
|
397
402
|
transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
|
|
398
|
-
|
|
403
|
+
// Same bare-identifier substitution for a literal prop: `onclick="set(limit)"`
|
|
404
|
+
// with `limit="5"` becomes `set(5)`.
|
|
405
|
+
const rewritten = substituteInExpr(body.replace(stateRegex, `$.${literal}`), literal);
|
|
399
406
|
return rewritten === body ? match : `on${evName}="${rewritten}"`;
|
|
400
407
|
});
|
|
401
408
|
}
|
package/runtime/hydrate.js
CHANGED
|
@@ -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/iterate.js
CHANGED
|
@@ -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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
}
|