@ape-egg/vibe 2.1.22 → 3.0.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.
Files changed (56) hide show
  1. package/README.md +112 -5
  2. package/boot.js +4 -4
  3. package/component.js +27 -29
  4. package/hot-module-refresh.js +4 -4
  5. package/index.js +26 -17
  6. package/llms.txt +36 -5
  7. package/package.json +20 -14
  8. package/runtime/affected.js +159 -36
  9. package/runtime/cleanup.js +45 -1
  10. package/runtime/component.js +360 -98
  11. package/runtime/conditionals.js +111 -14
  12. package/runtime/debug.js +24 -0
  13. package/runtime/dispatch.js +172 -0
  14. package/runtime/hydrate.js +277 -110
  15. package/runtime/index.js +189 -65
  16. package/runtime/iterate.js +125 -50
  17. package/runtime/iteration-utils.js +59 -8
  18. package/runtime/manifest.js +77 -2
  19. package/runtime/parse.js +81 -11
  20. package/runtime/pre-compiled-iterations.js +19 -6
  21. package/runtime/pre-compiled-manifest.js +13 -4
  22. package/runtime/staging.js +153 -0
  23. package/runtime/state.js +31 -0
  24. package/runtime/tracking.js +173 -0
  25. package/runtime/utils.js +155 -78
  26. package/spa.js +206 -0
  27. package/vibe.css +8 -4
  28. package/CHANGELOG.md +0 -1159
  29. package/ROADMAP.md +0 -397
  30. package/compiler/bin/vibe-compile.js +0 -121
  31. package/compiler/native/.gitkeep +0 -0
  32. package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
  33. package/compiler/native/vibe-compiler-linux-x64 +0 -0
  34. package/compiler/src/Cargo.lock +0 -2023
  35. package/compiler/src/Cargo.toml +0 -38
  36. package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +0 -241
  37. package/compiler/src/compiler/binding_case.rs +0 -88
  38. package/compiler/src/compiler/compile.rs +0 -2522
  39. package/compiler/src/compiler/component_tagger.rs +0 -469
  40. package/compiler/src/compiler/iteration_optimizer.rs +0 -455
  41. package/compiler/src/compiler/js_analyzer.rs +0 -715
  42. package/compiler/src/compiler/manifest_builder.rs +0 -693
  43. package/compiler/src/compiler/mod.rs +0 -15
  44. package/compiler/src/compiler/name_binding_protect.rs +0 -207
  45. package/compiler/src/compiler/reassignment_analyzer.rs +0 -456
  46. package/compiler/src/compiler/state_extractor.rs +0 -263
  47. package/compiler/src/compiler/value_stamper.rs +0 -921
  48. package/compiler/src/compiler/watcher.rs +0 -1147
  49. package/compiler/src/config.rs +0 -239
  50. package/compiler/src/main.rs +0 -347
  51. package/compiler/src/parser/element.rs +0 -96
  52. package/compiler/src/parser/html.rs +0 -1004
  53. package/compiler/src/parser/mod.rs +0 -8
  54. package/runtime/pre-compiled-manifest.test.mjs +0 -58
  55. package/runtime/scope.js +0 -50
  56. package/test-results/.last-run.json +0 -4
@@ -1,469 +0,0 @@
1
- use html5ever::parse_document;
2
- use html5ever::tendril::TendrilSink;
3
- use html5ever::serialize::{serialize, SerializeOpts};
4
- use markup5ever_rcdom::{RcDom, NodeData, Handle, Node, SerializableHandle};
5
- use markup5ever::{QualName, LocalName, Namespace};
6
- use regex::Regex;
7
- use serde_json::Value;
8
- use std::collections::HashSet;
9
- use std::path::PathBuf;
10
- use std::cell::RefCell;
11
- use std::sync::OnceLock;
12
- use crate::compiler::state_extractor::StateExtractor;
13
-
14
- /// Matches a `component(...)` state-registration call in a component's inline
15
- /// script. The `\b` keeps it from firing on `subcomponent(`; resolvable or not,
16
- /// its presence is what tells the tagger this wrapper owns component-local state.
17
- fn component_call_regex() -> &'static Regex {
18
- static RE: OnceLock<Regex> = OnceLock::new();
19
- RE.get_or_init(|| Regex::new(r"\bcomponent\s*\(").unwrap())
20
- }
21
-
22
- pub struct ComponentTagger;
23
-
24
- pub struct TaggedResult {
25
- pub html: String,
26
- pub state: Value,
27
- }
28
-
29
- impl ComponentTagger {
30
- /// Find component wrappers, add deterministic IDs, and structure state
31
- pub fn tag_components(html: &str, base_path: &PathBuf) -> Result<TaggedResult, String> {
32
- // Extract ALL <template> content to protect it from HTML parser
33
- // HTML parsers can strip content from <template> tags during serialization
34
- let template_regex = regex::Regex::new(r"(?s)<template[^>]*>.*?</template>").unwrap();
35
- let mut template_placeholders: Vec<String> = Vec::new();
36
- let mut html_with_placeholders = html.to_string();
37
-
38
- for (i, mat) in template_regex.find_iter(html).enumerate() {
39
- let content = mat.as_str();
40
- let placeholder = format!("<!--VIBE_TEMPLATE_PLACEHOLDER_{}-->", i);
41
- template_placeholders.push(content.to_string());
42
- html_with_placeholders = html_with_placeholders.replace(content, &placeholder);
43
- }
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
-
50
- // Parse HTML (with placeholders instead of actual vibe-dehydrate content)
51
- let dom = parse_document(RcDom::default(), Default::default())
52
- .from_utf8()
53
- .read_from(&mut html_with_placeholders.as_bytes())
54
- .map_err(|e| format!("Failed to parse HTML: {:?}", e))?;
55
-
56
- // Find all component wrappers and extract their state
57
- let component_counter = RefCell::new(0);
58
- let component_states = RefCell::new(Vec::new());
59
- Self::walk_tag_and_extract(&dom.document, &component_counter, &component_states, html);
60
-
61
- // Serialize back to HTML
62
- let mut modified_html_bytes = Vec::new();
63
- serialize(
64
- &mut modified_html_bytes,
65
- &SerializableHandle::from(dom.document.clone()),
66
- SerializeOpts::default()
67
- ).map_err(|e| format!("Failed to serialize HTML: {:?}", e))?;
68
-
69
- let mut modified_html = String::from_utf8(modified_html_bytes)
70
- .map_err(|e| format!("Failed to convert HTML to UTF-8: {}", e))?;
71
-
72
- // Restore original casing of name-binding expressions html5ever lowercased.
73
- modified_html = crate::compiler::binding_case::restore(&modified_html, &binding_cases);
74
-
75
- // Restore template content from placeholders
76
- for (i, content) in template_placeholders.iter().enumerate() {
77
- let placeholder = format!("<!--VIBE_TEMPLATE_PLACEHOLDER_{}-->", i);
78
- modified_html = modified_html.replace(&placeholder, content);
79
- }
80
-
81
- // Extract all state from HTML
82
- let all_state = StateExtractor::extract_from_html(html, base_path)?;
83
-
84
- // Build a set of all keys that are in component states
85
- let mut component_keys = HashSet::new();
86
- for (_, comp_state) in component_states.borrow().iter() {
87
- if let Value::Object(comp_map) = comp_state {
88
- for key in comp_map.keys() {
89
- component_keys.insert(key.clone());
90
- }
91
- }
92
- }
93
-
94
- // Filter global state to only include keys NOT in component states
95
- let mut merged_state = if let Value::Object(all_map) = all_state {
96
- let mut global_only = serde_json::Map::new();
97
- for (key, value) in all_map {
98
- // Only include in global if it's not a component state key
99
- if !component_keys.contains(&key) {
100
- global_only.insert(key, value);
101
- }
102
- }
103
- global_only
104
- } else {
105
- serde_json::Map::new()
106
- };
107
-
108
- // Add component states under their IDs
109
- for (component_id, comp_state) in component_states.borrow().iter() {
110
- if let Value::Object(comp_map) = comp_state {
111
- // Only add if component has non-empty state
112
- if !comp_map.is_empty() {
113
- merged_state.insert(component_id.clone(), comp_state.clone());
114
- }
115
- }
116
- }
117
-
118
- let merged_state = Value::Object(merged_state);
119
-
120
- Ok(TaggedResult {
121
- html: modified_html,
122
- state: merged_state,
123
- })
124
- }
125
-
126
- /// Recursively walk DOM, add data-vibe-component-id, and extract component state
127
- fn walk_tag_and_extract(
128
- node: &Handle,
129
- counter: &RefCell<usize>,
130
- component_states: &RefCell<Vec<(String, Value)>>,
131
- original_html: &str,
132
- ) {
133
- // Check if this is a component wrapper
134
- if let NodeData::Element { name, attrs, .. } = &node.data {
135
- let tag_name = name.local.as_ref();
136
- let borrowed_attrs = attrs.borrow();
137
-
138
- // Check if it's <component> (without src) or <div class="component"> (without src)
139
- let is_component = tag_name == "component" && !Self::has_src_attr(&borrowed_attrs);
140
- let is_div_component = tag_name == "div"
141
- && Self::has_class_component(&borrowed_attrs)
142
- && !Self::has_src_attr(&borrowed_attrs);
143
-
144
- if is_component || is_div_component {
145
- drop(borrowed_attrs); // Release borrow before checking innerHTML
146
-
147
- // Only extract state from DIRECT <script> children — not the full subtree.
148
- // Using the full subtree caused the Layout wrapper (a <component> created by
149
- // inline_component_elements that contains all nested component scripts) to be
150
- // treated as a stateful component with the merged state of all its descendants.
151
- // That made rewrite_this_to_component_id rewrite every @[this.xxx] in the page
152
- // to @[_c0.xxx] before child components could claim their own bindings.
153
- let direct_scripts_html = Self::direct_scripts_html(node);
154
-
155
- // A component must be tagged whenever it REGISTERS local state via a
156
- // `component(...)` call — that is what makes `this.X` in its markup
157
- // resolvable at runtime. Whether the initial state VALUES are statically
158
- // resolvable is a separate concern (it only governs FOUC value-stamping).
159
- // A state built from a runtime expression — e.g.
160
- // `component({ levels: Array.from({ length: 25 }, ...) })` — resolves to
161
- // an empty object here, but the component still needs its id + `this.`
162
- // rewrite, or a compiled `<!-- each this.levels -->` ships a raw `this.`
163
- // the runtime can't resolve when a restored conditional branch re-parses
164
- // the template (no wrapper-tag ancestor to fall back to).
165
- let registers_state = component_call_regex().is_match(&direct_scripts_html);
166
-
167
- if registers_state {
168
- let comp_state = match StateExtractor::extract_from_html(
169
- &direct_scripts_html,
170
- &PathBuf::from(".")
171
- ) {
172
- Ok(Value::Object(state)) => state,
173
- _ => serde_json::Map::new(),
174
- };
175
-
176
- let component_id = format!("_c{}", *counter.borrow());
177
- *counter.borrow_mut() += 1;
178
-
179
- // Add data-vibe-component-id attribute
180
- let mut attrs_mut = attrs.borrow_mut();
181
- attrs_mut.push(markup5ever::Attribute {
182
- name: QualName::new(
183
- None,
184
- Namespace::from(""),
185
- LocalName::from("data-vibe-component-id"),
186
- ),
187
- value: component_id.clone().into(),
188
- });
189
- drop(attrs_mut);
190
-
191
- // Rewrite this.property to componentId.property in the node's children
192
- // This allows ValueStamper to properly evaluate component-scoped expressions
193
- Self::rewrite_this_to_component_id(node, &component_id);
194
-
195
- // Register any statically-resolvable initial state under the id. May be
196
- // empty (runtime-built state); the runtime fills it in when the inlined
197
- // `component(...)` call runs on mount.
198
- component_states.borrow_mut().push((component_id, Value::Object(comp_state)));
199
- }
200
- }
201
- }
202
-
203
- // Recurse into children
204
- for child in node.children.borrow().iter() {
205
- Self::walk_tag_and_extract(child, counter, component_states, original_html);
206
- }
207
- }
208
-
209
- /// Check if element has src attribute
210
- fn has_src_attr(attrs: &[markup5ever::Attribute]) -> bool {
211
- attrs.iter().any(|attr| attr.name.local.as_ref() == "src")
212
- }
213
-
214
- /// Check if element has class="component"
215
- fn has_class_component(attrs: &[markup5ever::Attribute]) -> bool {
216
- attrs.iter().any(|attr| {
217
- attr.name.local.as_ref() == "class"
218
- && attr.value.as_ref().split_whitespace().any(|c| c == "component")
219
- })
220
- }
221
-
222
- /// Concatenate a node's DIRECT `<script>` children as `<script>…</script>`.
223
- /// Reads raw text (not serialized) so JS operators (`>`, `&&`) aren't
224
- /// HTML-escaped — the escaped source would fail the AST parse and drop a
225
- /// `component(stateVar)` call to the regex fallback. Used both to extract a
226
- /// wrapper's own state and to detect nested component boundaries.
227
- fn direct_scripts_html(node: &Handle) -> String {
228
- let mut out = String::new();
229
- for child in node.children.borrow().iter() {
230
- if let NodeData::Element { name: ref child_name, .. } = child.data {
231
- if child_name.local.as_ref() == "script" {
232
- let mut script_text = String::new();
233
- for grandchild in child.children.borrow().iter() {
234
- if let NodeData::Text { ref contents } = grandchild.data {
235
- script_text.push_str(&contents.borrow());
236
- }
237
- }
238
- out.push_str("<script>");
239
- out.push_str(&script_text);
240
- out.push_str("</script>");
241
- }
242
- }
243
- }
244
- out
245
- }
246
-
247
- /// Is this node a component wrapper that registers its OWN local state? Such
248
- /// a node gets its own `_cN` id and `this.`→id pass when `walk_tag_and_extract`
249
- /// reaches it, so an ancestor's rewrite must stop here — descending would let
250
- /// the ancestor claim the nested component's bindings with the wrong id (the
251
- /// DebugContent→ScalingModal empty-legend bug).
252
- fn is_state_registering_component(node: &Handle) -> bool {
253
- if let NodeData::Element { name, attrs, .. } = &node.data {
254
- let tag_name = name.local.as_ref();
255
- let borrowed = attrs.borrow();
256
- let is_component = tag_name == "component" && !Self::has_src_attr(&borrowed);
257
- let is_div_component = tag_name == "div"
258
- && Self::has_class_component(&borrowed)
259
- && !Self::has_src_attr(&borrowed);
260
- if !(is_component || is_div_component) {
261
- return false;
262
- }
263
- drop(borrowed);
264
- return component_call_regex().is_match(&Self::direct_scripts_html(node));
265
- }
266
- false
267
- }
268
-
269
- /// Rewrite `this.X` to `componentId.X` throughout a component's subtree:
270
- /// inside `@[...]` bindings (text + attributes, nested paths and multi-ref
271
- /// expressions) and inside if/each/else-if directive comments. Resolving the
272
- /// directives at build time is what lets a component-local `<!-- if this.x -->`
273
- /// work on compiled (manifest-restored) pages, where the runtime can't fall
274
- /// back to a `data-vibe-component-id` ancestor lookup.
275
- fn rewrite_this_to_component_id(node: &Handle, component_id: &str) {
276
- use regex::Regex;
277
- // The whole `@[...]` binding; `this.` is resolved within it so nested
278
- // paths (this.x.y) and expressions (this.a + this.b) are all covered.
279
- let binding_regex = Regex::new(r"@\[[^\]]*\]").unwrap();
280
- let this_prop = Regex::new(r"\bthis\.").unwrap();
281
- // `$.this.X` writes that live in event-handler bodies (onclick="$.this.mode
282
- // = 'edit'"), outside any `@[...]`. The runtime lowers these via its
283
- // STATE_THIS_PROP_REGEX pass (runtime/component.js); the compiler must do
284
- // the same or compiled pages ship a literal `$.this.X` that resolves to
285
- // undefined when the native handler fires.
286
- let state_this_prop = Regex::new(r"\$\.this\.(\w+)").unwrap();
287
- Self::rewrite_node_recursive(node, &binding_regex, &this_prop, &state_this_prop, component_id);
288
- }
289
-
290
- /// Resolve `this.` to `componentId.` inside every `@[...]` binding in a string.
291
- fn rewrite_bindings(s: &str, binding_regex: &Regex, this_prop: &Regex, component_id: &str) -> String {
292
- let replacement = format!("{}.", component_id);
293
- binding_regex
294
- .replace_all(s, |caps: &regex::Captures| {
295
- this_prop.replace_all(&caps[0], replacement.as_str()).to_string()
296
- })
297
- .to_string()
298
- }
299
-
300
- /// Recursively rewrite this.property in text, attributes, and directive comments
301
- fn rewrite_node_recursive(node: &Handle, binding_regex: &Regex, this_prop: &Regex, state_this_prop: &Regex, component_id: &str) {
302
- // Rewrite text content bindings
303
- if let NodeData::Text { ref contents } = node.data {
304
- let mut text = contents.borrow_mut();
305
- let new_text = Self::rewrite_bindings(&text, binding_regex, this_prop, component_id);
306
- *text = new_text.into();
307
- }
308
-
309
- // Rewrite attribute bindings (`@[...]`) and event-handler `$.this.X` writes.
310
- if let NodeData::Element { ref attrs, .. } = node.data {
311
- let mut attrs_mut = attrs.borrow_mut();
312
- for attr in attrs_mut.iter_mut() {
313
- let mut new_value = Self::rewrite_bindings(&attr.value, binding_regex, this_prop, component_id);
314
- if new_value.contains("$.this.") {
315
- new_value = state_this_prop
316
- .replace_all(&new_value, |c: &regex::Captures| format!("$.{}.{}", component_id, &c[1]))
317
- .to_string();
318
- }
319
- attr.value = new_value.into();
320
- }
321
- }
322
-
323
- // Directive comments are immutable in the RcDom, so swap any if/each/else-if
324
- // comment carrying a `this.` expression for a freshly-built comment node.
325
- {
326
- let mut children = node.children.borrow_mut();
327
- for child in children.iter_mut() {
328
- if let NodeData::Comment { ref contents } = child.data {
329
- let text = contents.to_string();
330
- let trimmed = text.trim_start();
331
- let is_directive = trimmed.starts_with("if ")
332
- || trimmed.starts_with("each ")
333
- || trimmed.starts_with("else if ");
334
- if is_directive && text.contains("this.") {
335
- let new_text = this_prop
336
- .replace_all(&text, format!("{}.", component_id).as_str())
337
- .to_string();
338
- *child = Node::new(NodeData::Comment { contents: new_text.into() });
339
- }
340
- }
341
- }
342
- }
343
-
344
- // Recurse into children — but STOP at a nested component that registers
345
- // its own state. It owns the `this.` inside it and gets its own id +
346
- // rewrite when walk_tag_and_extract reaches it; descending here would
347
- // claim its bindings with this ancestor's id (the empty-legend bug).
348
- for child in node.children.borrow().iter() {
349
- if Self::is_state_registering_component(child) {
350
- continue;
351
- }
352
- Self::rewrite_node_recursive(child, binding_regex, this_prop, state_this_prop, component_id);
353
- }
354
- }
355
- }
356
-
357
- #[cfg(test)]
358
- mod tests {
359
- use super::*;
360
-
361
-
362
-
363
-
364
-
365
- #[test]
366
- fn tag_single_component() {
367
- let html = r#"<!DOCTYPE html><html><body><component><script>component({ count: 0 })</script><div>@[this.count]</div></component></body></html>"#;
368
-
369
- let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
370
-
371
- // Should have data-vibe-component-id in output
372
- assert!(result.html.contains("data-vibe-component-id=\"_c0\""));
373
- }
374
-
375
- #[test]
376
- fn rewrites_this_in_directive_comments_and_nested_bindings() {
377
- // Conditionals/iterations keep `this.` in their directive comments and
378
- // bindings can be nested (this.x.y). Compiled (manifest-restored) pages
379
- // can't resolve `this.` via a runtime wrapper-tag lookup, so the compiler
380
- // must resolve it to the component id at build time.
381
- let html = r#"<!DOCTYPE html><html><body><component><script>const s = { x: null }; component(s);</script><page-content><!-- if this.x --><span>@[this.x.name]</span><!-- /if --><!-- each this.items as it --><b>@[it]</b><!-- /each --></page-content></component></body></html>"#;
382
-
383
- let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
384
-
385
- assert!(result.html.contains("if _c0.x"), "if comment not rewritten: {}", result.html);
386
- assert!(result.html.contains("@[_c0.x.name]"), "nested binding not rewritten: {}", result.html);
387
- assert!(result.html.contains("each _c0.items as it"), "each comment not rewritten: {}", result.html);
388
- // A non-this global expression must be left alone.
389
- assert!(!result.html.contains("_c0.items as _c0"), "over-rewrote loop alias: {}", result.html);
390
- }
391
-
392
- #[test]
393
- fn tag_component_called_with_variable() {
394
- // BrawlerDetailContent shape: state held in a `const`, passed to
395
- // component(state) as a bare identifier, with a top-level
396
- // `<!-- if this.X -->`. The wrapper must still be tagged so the
397
- // conditional can resolve `this.` at runtime.
398
- let html = r#"<!DOCTYPE html><html><body><component><script>const state = { currentCharacter: null }; component(state);</script><page-content><!-- if this.currentCharacter --><span>@[this.currentCharacter]</span><!-- /if --></page-content></component></body></html>"#;
399
-
400
- let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
401
-
402
- assert!(
403
- result.html.contains("data-vibe-component-id=\"_c0\""),
404
- "wrapper not tagged; html: {}",
405
- result.html
406
- );
407
- }
408
-
409
- #[test]
410
- fn tags_component_with_runtime_built_state() {
411
- // AccountProgression shape: the ONLY state key is built from a runtime
412
- // expression (`Array.from`), so static extraction resolves to an empty
413
- // object. The wrapper must still be tagged and its `<!-- each this.X -->`
414
- // rewritten — otherwise a restored conditional branch re-parses a raw
415
- // `this.levels` the runtime can't resolve, and the loop renders nothing.
416
- let html = r#"<!DOCTYPE html><html><body><component><script>component({ levels: Array.from({ length: 25 }, (_, i) => i + 1) });</script><slides><!-- each this.levels as lvl --><slide>@[lvl]</slide><!-- /each --></slides></component></body></html>"#;
417
-
418
- let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
419
-
420
- assert!(
421
- result.html.contains("data-vibe-component-id=\"_c0\""),
422
- "runtime-built-state component not tagged: {}",
423
- result.html
424
- );
425
- assert!(
426
- result.html.contains("each _c0.levels as lvl"),
427
- "each comment not rewritten: {}",
428
- result.html
429
- );
430
- }
431
-
432
- #[test]
433
- fn nested_component_this_resolves_to_own_id() {
434
- // DebugContent → ScalingModal shape: a stateful component nested inside
435
- // another stateful component, each owning its own `this.`. The outer's
436
- // this.→id rewrite must STOP at the inner component boundary — otherwise
437
- // it claims the inner's bindings/directives with the OUTER id before the
438
- // inner is reached, and the inner's live state (registered under its own
439
- // runtime id) never reaches the markup → empty each-loops, dead bindings.
440
- let html = r#"<!DOCTYPE html><html><body><component><script>component({ outer: 1 })</script><outer-mark>@[this.outer]</outer-mark><component><script>component({ inner: 2, items: [] })</script><modal-root open="@[this.open]"><!-- each this.items as it --><inner-mark>@[this.inner]</inner-mark><!-- /each --></modal-root></component></component></body></html>"#;
441
-
442
- let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
443
-
444
- // Outer is _c0 (visited first), inner is _c1 (reached on recursion).
445
- assert!(result.html.contains("@[_c0.outer]"), "outer binding wrong: {}", result.html);
446
- assert!(result.html.contains("@[_c1.inner]"), "inner binding not rewritten to own id: {}", result.html);
447
- assert!(result.html.contains("each _c1.items as it"), "inner each not rewritten to own id: {}", result.html);
448
- assert!(result.html.contains("open=\"@[_c1.open]\""), "inner attr binding not rewritten to own id: {}", result.html);
449
- // The outer must NOT have claimed any of the inner's bindings/directives.
450
- assert!(!result.html.contains("@[_c0.inner]"), "outer claimed inner text binding: {}", result.html);
451
- assert!(!result.html.contains("each _c0.items"), "outer claimed inner each: {}", result.html);
452
- assert!(!result.html.contains("@[_c0.open]"), "outer claimed inner attr binding: {}", result.html);
453
- }
454
-
455
- #[test]
456
- fn tag_multiple_components() {
457
- // Each wrapper registers component-local state, so all three are tagged
458
- // with sequential ids. (A bare `<component></component>` with no state
459
- // call is intentionally NOT tagged — it owns no `this.` to resolve.)
460
- let html = r#"<!DOCTYPE html><html><body><component><script>component({ a: 1 })</script></component><component><script>component({ b: 2 })</script></component><component><script>component({ c: 3 })</script></component></body></html>"#;
461
-
462
- let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
463
-
464
- // Should have _c0, _c1, _c2
465
- assert!(result.html.contains("data-vibe-component-id=\"_c0\""));
466
- assert!(result.html.contains("data-vibe-component-id=\"_c1\""));
467
- assert!(result.html.contains("data-vibe-component-id=\"_c2\""));
468
- }
469
- }