@ape-egg/vibe 2.1.10 → 2.1.11
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 +7 -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/binding_case.rs +88 -0
- package/compiler/src/compiler/component_tagger.rs +8 -0
- package/compiler/src/compiler/manifest_builder.rs +21 -1
- package/compiler/src/compiler/mod.rs +1 -0
- package/package.json +1 -1
- package/runtime/utils.js +10 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.1.11] - 2026-06-22
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **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`.
|
|
8
|
+
- **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`.
|
|
9
|
+
|
|
3
10
|
## [2.1.10] - 2026-06-21
|
|
4
11
|
|
|
5
12
|
### Fixed
|
|
Binary file
|
|
Binary file
|
package/compiler/src/Cargo.lock
CHANGED
package/compiler/src/Cargo.toml
CHANGED
|
@@ -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: ®ex::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
|
-
|
|
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
|
}
|
package/package.json
CHANGED
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('[')
|
|
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) {
|