@ape-egg/vibe 1.3.2 → 1.6.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.
@@ -4,20 +4,23 @@ 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::collections::HashMap;
7
+ use std::collections::{HashMap, BTreeMap};
8
+ use crate::compiler::iteration_optimizer::build_iteration_optimizations;
8
9
 
9
10
  pub struct ManifestBuilder {
10
11
  binding_regex: Regex,
12
+ name_binding_fix_regex: Regex,
11
13
  }
12
14
 
13
15
  impl ManifestBuilder {
14
16
  pub fn new() -> Self {
15
17
  Self {
16
18
  binding_regex: Regex::new(r"@\[((?:[^\[\]]|\[[^\]]*\])+)\]").unwrap(),
19
+ name_binding_fix_regex: Regex::new(r#"(@\[[^\]]+\])="+"#).unwrap(),
17
20
  }
18
21
  }
19
22
 
20
- pub fn build_from_html(&self, html: &str, _state: &Value) -> Result<ManifestNode, String> {
23
+ pub fn build_from_html(&self, html: &str, _state: &Value, iterations_as_is: bool) -> Result<ManifestNode, String> {
21
24
  // Parse HTML
22
25
  let dom = parse_document(RcDom::default(), Default::default())
23
26
  .from_utf8()
@@ -25,11 +28,59 @@ impl ManifestBuilder {
25
28
  .map_err(|e| format!("Failed to parse HTML: {:?}", e))?;
26
29
 
27
30
  // Walk tree starting from document root
28
- let root_node = self.walk_node(&dom.document, &mut 0);
31
+ let mut root_node = self.walk_node(&dom.document, &mut 0);
32
+
33
+ // Inject compiled batch functions directly into iteration nodes (unless disabled)
34
+ if !iterations_as_is {
35
+ let compiled_functions = build_iteration_optimizations(html);
36
+ if let Some(optimizations) = compiled_functions {
37
+ self.inject_compiled_functions(&mut root_node, &optimizations.iterations);
38
+ }
39
+ }
29
40
 
30
41
  Ok(root_node)
31
42
  }
32
43
 
44
+ /// Recursively inject compiled batch functions into iteration nodes
45
+ fn inject_compiled_functions(&self, node: &mut ManifestNode, compiled_map: &HashMap<String, crate::compiler::iteration_optimizer::CompiledIteration>) {
46
+ // Check if this is an iteration node with a template
47
+ if node.node_type.as_deref() == Some("iteration") {
48
+ if let Some(ref mut compiled_data) = node.compiled {
49
+ if let Some(ref restoration) = compiled_data.restoration {
50
+ if let Some(ref template) = restoration.template {
51
+ // Generate hash for this template (same logic as optimizer)
52
+ let template_hash = self.generate_template_hash(template);
53
+
54
+ // Look up compiled function
55
+ if let Some(compiled) = compiled_map.get(&template_hash) {
56
+ compiled_data.iterations = Some(IterationData {
57
+ batch_fn: Some(compiled.batch_fn.clone()),
58
+ item_alias: Some(compiled.item_alias.clone()),
59
+ index_alias: Some(compiled.index_alias.clone()),
60
+ });
61
+ }
62
+ }
63
+ }
64
+ }
65
+ }
66
+
67
+ // Recursively process children
68
+ for child in node.children.values_mut() {
69
+ self.inject_compiled_functions(child, compiled_map);
70
+ }
71
+ }
72
+
73
+ /// Generate stable hash for template (matches iteration_optimizer logic)
74
+ fn generate_template_hash(&self, template: &str) -> String {
75
+ use std::collections::hash_map::DefaultHasher;
76
+ use std::hash::{Hash, Hasher};
77
+
78
+ let mut hasher = DefaultHasher::new();
79
+ template.trim().hash(&mut hasher);
80
+ let hash = hasher.finish();
81
+ format!("iter_{:x}", hash)
82
+ }
83
+
33
84
  fn walk_node(&self, node: &Handle, _counter: &mut usize) -> ManifestNode {
34
85
  self.walk_node_impl(node, &node.children.borrow())
35
86
  }
@@ -38,9 +89,13 @@ impl ManifestBuilder {
38
89
  let mut manifest_node = ManifestNode {
39
90
  element: None,
40
91
  parsed: vec![],
41
- children: HashMap::new(),
42
- hyperspeed_restoration: None,
92
+ children: BTreeMap::new(),
93
+ compiled: None,
43
94
  node_type: None,
95
+ attributes: None,
96
+ name_bindings: None,
97
+ meta: None,
98
+ runtime: None,
44
99
  };
45
100
 
46
101
  match &node.data {
@@ -50,8 +105,11 @@ impl ManifestBuilder {
50
105
  // Check for @[...] bindings
51
106
  if self.binding_regex.is_match(&text) {
52
107
  let parsed = self.split_by_bindings(&text);
53
- manifest_node.hyperspeed_restoration = Some(RestorationData {
54
- parsed: Some(parsed),
108
+ manifest_node.compiled = Some(CompiledData {
109
+ restoration: Some(RestorationData {
110
+ parsed: Some(parsed),
111
+ ..Default::default()
112
+ }),
55
113
  ..Default::default()
56
114
  });
57
115
  }
@@ -60,20 +118,48 @@ impl ManifestBuilder {
60
118
  NodeData::Element { name, attrs, .. } => {
61
119
  let _tag_name = name.local.to_string();
62
120
 
63
- // Check attribute bindings
64
- let mut attr_bindings = HashMap::new();
65
- for attr in attrs.borrow().iter() {
121
+ // Check attribute bindings (both value and name bindings)
122
+ let mut attr_bindings = BTreeMap::new();
123
+ let mut name_bindings: Vec<Option<String>> = Vec::new();
124
+ let borrowed_attrs = attrs.borrow();
125
+
126
+ for (idx, attr) in borrowed_attrs.iter().enumerate() {
66
127
  let attr_name = attr.name.local.to_string();
67
128
  let attr_value = attr.value.to_string();
68
129
 
69
- if self.binding_regex.is_match(&attr_value) {
130
+ // Check for name bindings (binding in attribute NAME)
131
+ if self.binding_regex.is_match(&attr_name) {
132
+ // Extend vec to include this index
133
+ while name_bindings.len() <= idx {
134
+ name_bindings.push(None);
135
+ }
136
+ name_bindings[idx] = Some(attr_name.clone());
137
+ }
138
+ // Check for attribute value bindings
139
+ else if self.binding_regex.is_match(&attr_value) {
70
140
  attr_bindings.insert(attr_name, attr_value);
71
141
  }
72
142
  }
73
143
 
144
+ // Store if we have any bindings
145
+ let has_name_bindings = name_bindings.iter().any(|b| b.is_some());
146
+
147
+ // Set runtime-compatible fields at node level
74
148
  if !attr_bindings.is_empty() {
75
- manifest_node.hyperspeed_restoration = Some(RestorationData {
76
- attributes: Some(attr_bindings),
149
+ manifest_node.attributes = Some(attr_bindings.clone());
150
+ }
151
+ if has_name_bindings {
152
+ manifest_node.name_bindings = Some(name_bindings.clone());
153
+ }
154
+
155
+ // Also store in compiled.restoration for restoration purposes
156
+ if !attr_bindings.is_empty() || has_name_bindings {
157
+ manifest_node.compiled = Some(CompiledData {
158
+ restoration: Some(RestorationData {
159
+ attributes: if !attr_bindings.is_empty() { Some(attr_bindings) } else { None },
160
+ name_bindings: if has_name_bindings { Some(name_bindings) } else { None },
161
+ ..Default::default()
162
+ }),
77
163
  ..Default::default()
78
164
  });
79
165
  }
@@ -87,6 +173,21 @@ impl ManifestBuilder {
87
173
  if trimmed.starts_with("each ") {
88
174
  manifest_node.node_type = Some("iteration".to_string());
89
175
 
176
+ // Extract expression: "each items as item" -> "items as item"
177
+ let expression = trimmed.strip_prefix("each ").unwrap_or("").to_string();
178
+
179
+ // Parse expression to extract parts: "items as item, index" or "items as item"
180
+ let parts: Vec<&str> = expression.split(" as ").collect();
181
+ let array_path = parts.get(0).unwrap_or(&"").trim().to_string();
182
+ let alias_part = parts.get(1).unwrap_or(&"").trim();
183
+ let (item_alias, index_alias) = if let Some(comma_pos) = alias_part.find(',') {
184
+ let item = alias_part[..comma_pos].trim().to_string();
185
+ let index = alias_part[comma_pos + 1..].trim().to_string();
186
+ (item, index)
187
+ } else {
188
+ (alias_part.to_string(), "index".to_string())
189
+ };
190
+
90
191
  // Find node index in siblings
91
192
  let node_idx = siblings.iter().position(|n| std::ptr::eq(n as *const _, node as *const _));
92
193
 
@@ -97,8 +198,26 @@ impl ManifestBuilder {
97
198
  String::new()
98
199
  };
99
200
 
100
- manifest_node.hyperspeed_restoration = Some(RestorationData {
101
- template: Some(template),
201
+ // Create minimal meta structure - runtime will populate template after parsing restored DOM
202
+ manifest_node.meta = Some(serde_json::json!({
203
+ "arrayPath": array_path,
204
+ "itemAlias": item_alias,
205
+ "indexAlias": index_alias
206
+ }));
207
+
208
+ // Runtime state
209
+ manifest_node.runtime = Some(serde_json::json!({
210
+ "instances": [],
211
+ "templateRemoved": false
212
+ }));
213
+
214
+ // Compiled restoration data
215
+ manifest_node.compiled = Some(CompiledData {
216
+ restoration: Some(RestorationData {
217
+ template: Some(template),
218
+ expression: Some(expression),
219
+ ..Default::default()
220
+ }),
102
221
  ..Default::default()
103
222
  });
104
223
  }
@@ -107,6 +226,7 @@ impl ManifestBuilder {
107
226
  if trimmed.starts_with("if ") {
108
227
  manifest_node.node_type = Some("conditional".to_string());
109
228
 
229
+ let expression = trimmed.strip_prefix("if ").unwrap_or("").to_string();
110
230
  let node_idx = siblings.iter().position(|n| std::ptr::eq(n as *const _, node as *const _));
111
231
 
112
232
  let template = if let Some(idx) = node_idx {
@@ -115,8 +235,24 @@ impl ManifestBuilder {
115
235
  String::new()
116
236
  };
117
237
 
118
- manifest_node.hyperspeed_restoration = Some(RestorationData {
119
- template: Some(template),
238
+ // Create minimal meta structure - runtime will populate branches after parsing restored DOM
239
+ manifest_node.meta = Some(serde_json::json!({
240
+ "expression": expression
241
+ }));
242
+
243
+ // Runtime state
244
+ manifest_node.runtime = Some(serde_json::json!({
245
+ "activeBranch": null,
246
+ "activeInstance": null,
247
+ "templateRemoved": false
248
+ }));
249
+
250
+ // Compiled restoration data
251
+ manifest_node.compiled = Some(CompiledData {
252
+ restoration: Some(RestorationData {
253
+ template: Some(template),
254
+ ..Default::default()
255
+ }),
120
256
  ..Default::default()
121
257
  });
122
258
  }
@@ -304,6 +440,11 @@ impl ManifestBuilder {
304
440
  }
305
441
  }
306
442
 
443
+ // Fix name bindings: Remove ="" added by html5ever serializer
444
+ // Name bindings like <icon @[section.icon]> get serialized as <icon @[section.icon]="">
445
+ // which is invalid HTML that browsers reject
446
+ html = self.name_binding_fix_regex.replace_all(&html, "$1").to_string();
447
+
307
448
  html
308
449
  }
309
450
  }
@@ -312,13 +453,26 @@ impl ManifestBuilder {
312
453
  pub struct ManifestNode {
313
454
  element: Option<()>, // Always null in static manifest
314
455
  parsed: Vec<String>,
315
- children: HashMap<String, ManifestNode>,
456
+ children: BTreeMap<String, ManifestNode>,
316
457
 
317
458
  #[allow(dead_code)]
318
- hyperspeed_restoration: Option<RestorationData>,
459
+ compiled: Option<CompiledData>,
319
460
 
320
461
  #[allow(dead_code)]
321
462
  node_type: Option<String>,
463
+
464
+ // Runtime-compatible fields
465
+ #[allow(dead_code)]
466
+ attributes: Option<BTreeMap<String, String>>,
467
+
468
+ #[allow(dead_code)]
469
+ name_bindings: Option<Vec<Option<String>>>,
470
+
471
+ #[allow(dead_code)]
472
+ meta: Option<Value>, // For conditionals and iterations
473
+
474
+ #[allow(dead_code)]
475
+ runtime: Option<Value>, // Runtime state
322
476
  }
323
477
 
324
478
  // Custom Serialize implementation to control output format
@@ -340,9 +494,29 @@ impl Serialize for ManifestNode {
340
494
  // Always include children
341
495
  map.serialize_entry("children", &self.children)?;
342
496
 
343
- // Optional: hyperspeedRestoration
344
- if let Some(ref restoration) = self.hyperspeed_restoration {
345
- map.serialize_entry("hyperspeedRestoration", restoration)?;
497
+ // Optional: attributes (runtime field)
498
+ if let Some(ref attributes) = self.attributes {
499
+ map.serialize_entry("attributes", attributes)?;
500
+ }
501
+
502
+ // Optional: nameBindings (runtime field)
503
+ if let Some(ref name_bindings) = self.name_bindings {
504
+ map.serialize_entry("nameBindings", name_bindings)?;
505
+ }
506
+
507
+ // Optional: meta (runtime field for conditionals/iterations)
508
+ if let Some(ref meta) = self.meta {
509
+ map.serialize_entry("meta", meta)?;
510
+ }
511
+
512
+ // Optional: runtime (runtime state)
513
+ if let Some(ref runtime) = self.runtime {
514
+ map.serialize_entry("runtime", runtime)?;
515
+ }
516
+
517
+ // Optional: compiled (contains restoration and iterations data)
518
+ if let Some(ref compiled) = self.compiled {
519
+ map.serialize_entry("compiled", compiled)?;
346
520
  }
347
521
 
348
522
  // Optional: type
@@ -360,10 +534,37 @@ pub struct RestorationData {
360
534
  pub parsed: Option<Vec<String>>,
361
535
 
362
536
  #[serde(skip_serializing_if = "Option::is_none")]
363
- pub attributes: Option<HashMap<String, String>>,
537
+ pub attributes: Option<BTreeMap<String, String>>,
364
538
 
365
539
  #[serde(skip_serializing_if = "Option::is_none")]
366
540
  pub template: Option<String>,
541
+
542
+ #[serde(skip_serializing_if = "Option::is_none")]
543
+ pub expression: Option<String>,
544
+
545
+ #[serde(skip_serializing_if = "Option::is_none", rename = "nameBindings")]
546
+ pub name_bindings: Option<Vec<Option<String>>>,
547
+ }
548
+
549
+ #[derive(Debug, Serialize, Default)]
550
+ pub struct IterationData {
551
+ #[serde(skip_serializing_if = "Option::is_none", rename = "batchFn")]
552
+ pub batch_fn: Option<String>,
553
+
554
+ #[serde(skip_serializing_if = "Option::is_none", rename = "itemAlias")]
555
+ pub item_alias: Option<String>,
556
+
557
+ #[serde(skip_serializing_if = "Option::is_none", rename = "indexAlias")]
558
+ pub index_alias: Option<String>,
559
+ }
560
+
561
+ #[derive(Debug, Serialize, Default)]
562
+ pub struct CompiledData {
563
+ #[serde(skip_serializing_if = "Option::is_none")]
564
+ pub restoration: Option<RestorationData>,
565
+
566
+ #[serde(skip_serializing_if = "Option::is_none")]
567
+ pub iterations: Option<IterationData>,
367
568
  }
368
569
 
369
570
  #[cfg(test)]
@@ -376,7 +577,7 @@ mod tests {
376
577
  let html = r#"<div>Hello @[name]</div>"#;
377
578
  let state = json!({ "name": "World" });
378
579
  let builder = ManifestBuilder::new();
379
- let manifest = builder.build_from_html(html, &state).unwrap();
580
+ let manifest = builder.build_from_html(html, &state, false).unwrap();
380
581
 
381
582
  // Should have children
382
583
  assert!(!manifest.children.is_empty());
@@ -387,7 +588,7 @@ mod tests {
387
588
  let html = r#"<input value="@[firstName]">"#;
388
589
  let state = json!({ "firstName": "John" });
389
590
  let builder = ManifestBuilder::new();
390
- let manifest = builder.build_from_html(html, &state).unwrap();
591
+ let manifest = builder.build_from_html(html, &state, false).unwrap();
391
592
 
392
593
  // Should have children
393
594
  assert!(!manifest.children.is_empty());
@@ -398,7 +599,7 @@ mod tests {
398
599
  let html = r#"<body><!-- each items as item --><div>@[item]</div><!-- /each --></body>"#;
399
600
  let state = json!({ "items": [1, 2, 3] });
400
601
  let builder = ManifestBuilder::new();
401
- let manifest = builder.build_from_html(html, &state).unwrap();
602
+ let manifest = builder.build_from_html(html, &state, false).unwrap();
402
603
 
403
604
  // Serialize to JSON to inspect
404
605
  let json = serde_json::to_string_pretty(&manifest).unwrap();
@@ -429,4 +630,28 @@ mod tests {
429
630
  let result = builder.split_by_bindings("Hello World");
430
631
  assert_eq!(result, vec!["Hello World"]);
431
632
  }
633
+
634
+ #[test]
635
+ fn build_multiple_name_bindings() {
636
+ let html = r#"<div test @[theme] @[size]></div>"#;
637
+ let state = json!({ "theme": "light", "size": "large" });
638
+ let builder = ManifestBuilder::new();
639
+ let manifest = builder.build_from_html(html, &state, false).unwrap();
640
+
641
+ // Navigate to div
642
+ let html_node = manifest.children.get("html_0").unwrap();
643
+ let body_node = html_node.children.get("body_1").unwrap();
644
+ let div_node = body_node.children.get("div_0").unwrap();
645
+
646
+ // Check name bindings (sparse array indexed by position)
647
+ let compiled = div_node.compiled.as_ref().unwrap();
648
+ let restoration = compiled.restoration.as_ref().unwrap();
649
+ let name_bindings = restoration.name_bindings.as_ref().unwrap();
650
+
651
+ // Attributes: [0] test, [1] @[theme], [2] @[size]
652
+ assert_eq!(name_bindings.len(), 3);
653
+ assert_eq!(name_bindings[0], None); // "test" is not a binding
654
+ assert_eq!(name_bindings[1], Some("@[theme]".to_string()));
655
+ assert_eq!(name_bindings[2], Some("@[size]".to_string()));
656
+ }
432
657
  }
@@ -2,7 +2,11 @@ pub mod compile;
2
2
  mod state_extractor;
3
3
  mod manifest_builder;
4
4
  mod value_stamper;
5
+ mod iteration_optimizer;
6
+ mod js_analyzer;
7
+ mod component_tagger;
8
+ pub mod watcher;
5
9
 
6
10
  pub use compile::Compiler;
7
11
  #[allow(unused_imports)]
8
- pub use compile::CompileStats;
12
+ pub use compile::{CompileStats, ManifestStats};
@@ -1,41 +1,128 @@
1
1
  use regex::Regex;
2
2
  use serde_json::{Value, Map};
3
+ use std::path::PathBuf;
4
+ use crate::compiler::js_analyzer::JsAnalyzer;
3
5
 
4
6
  pub struct StateExtractor;
5
7
 
6
8
  impl StateExtractor {
7
9
  /// Extract state from all vibe() and component() calls in HTML
8
- pub fn extract_from_html(html: &str) -> Result<Value, String> {
10
+ pub fn extract_from_html(html: &str, base_path: &PathBuf) -> Result<Value, String> {
11
+ // Try modern approach: parse script blocks with JsAnalyzer
12
+ if let Some(state) = Self::extract_from_scripts(html, base_path) {
13
+ return Ok(state);
14
+ }
15
+
16
+ // Fallback: legacy regex-based extraction for inline object literals
17
+ Self::extract_with_regex(html)
18
+ }
19
+
20
+ /// Extract state from <script> blocks using JavaScript AST analysis
21
+ fn extract_from_scripts(html: &str, base_path: &PathBuf) -> Option<Value> {
22
+ let script_regex = Regex::new(r#"(?s)<script[^>]*>(.*?)</script>"#).unwrap();
23
+ let mut analyzer = JsAnalyzer::new(base_path.clone());
24
+
9
25
  let mut merged_state = Map::new();
10
26
 
11
- // Find vibe({ ... }) and state({ ... }) patterns
12
- let vibe_regex = Regex::new(
13
- r"(?:vibe|state)\s*\(\s*\{([^}]+(?:\{[^}]*\}[^}]*)*)\}\s*[,)]"
14
- ).map_err(|e| format!("Failed to compile vibe regex: {}", e))?;
27
+ for cap in script_regex.captures_iter(html) {
28
+ let script = cap.get(1)?.as_str();
15
29
 
16
- for caps in vibe_regex.captures_iter(html) {
17
- let obj_literal = &caps[1];
18
- let state = Self::parse_object_literal(obj_literal)?;
19
- Self::merge_into(&mut merged_state, state);
30
+ // Parse and extract state with AST analysis
31
+ if let Some(Value::Object(state)) = analyzer.extract_state(script) {
32
+ for (k, v) in state {
33
+ merged_state.insert(k, v);
34
+ }
35
+ }
20
36
  }
21
37
 
22
- // Find component({ ... }) patterns
23
- let component_regex = Regex::new(
24
- r"component\s*\(\s*\{([^}]+(?:\{[^}]*\}[^}]*)*)\}\s*[,)]"
25
- ).map_err(|e| format!("Failed to compile component regex: {}", e))?;
38
+ if merged_state.is_empty() {
39
+ None
40
+ } else {
41
+ Some(Value::Object(merged_state))
42
+ }
43
+ }
26
44
 
27
- for caps in component_regex.captures_iter(html) {
28
- let obj_literal = &caps[1];
29
- let component_state = Self::parse_object_literal(obj_literal)?;
45
+ /// Legacy regex-based extraction (fallback)
46
+ fn extract_with_regex(html: &str) -> Result<Value, String> {
47
+ let mut merged_state = Map::new();
48
+
49
+ // Find vibe({ ... }) and state({ ... }) patterns using brace counting
50
+ let start_regex = Regex::new(r"(?:vibe|state)\s*\(\s*\{").unwrap();
30
51
 
31
- // Component state needs namespacing with component ID
32
- // For now, merge directly (components already resolved)
33
- Self::merge_into(&mut merged_state, component_state);
52
+ for start_match in start_regex.find_iter(html) {
53
+ if let Some(obj_literal) = Self::extract_balanced_object(&html[start_match.end()..]) {
54
+ match Self::parse_object_literal(&obj_literal) {
55
+ Ok(state) => Self::merge_into(&mut merged_state, state),
56
+ Err(_) => continue, // Skip unparseable state
57
+ }
58
+ }
59
+ }
60
+
61
+ // Find component({ ... }) patterns
62
+ let component_start_regex = Regex::new(r"component\s*\(\s*\{").unwrap();
63
+
64
+ for start_match in component_start_regex.find_iter(html) {
65
+ if let Some(obj_literal) = Self::extract_balanced_object(&html[start_match.end()..]) {
66
+ match Self::parse_object_literal(&obj_literal) {
67
+ Ok(component_state) => Self::merge_into(&mut merged_state, component_state),
68
+ Err(_) => continue,
69
+ }
70
+ }
34
71
  }
35
72
 
36
73
  Ok(Value::Object(merged_state))
37
74
  }
38
75
 
76
+ /// Extract balanced object literal content (everything between { and matching })
77
+ /// Input should start right after the opening {
78
+ fn extract_balanced_object(s: &str) -> Option<String> {
79
+ let mut depth = 1;
80
+ let mut in_string = false;
81
+ let mut escape_next = false;
82
+ let mut quote_char = '\0';
83
+ let mut result = String::new();
84
+
85
+ for ch in s.chars() {
86
+ if escape_next {
87
+ result.push(ch);
88
+ escape_next = false;
89
+ continue;
90
+ }
91
+
92
+ if ch == '\\' {
93
+ result.push(ch);
94
+ escape_next = true;
95
+ continue;
96
+ }
97
+
98
+ if !in_string {
99
+ if ch == '"' || ch == '\'' || ch == '`' {
100
+ in_string = true;
101
+ quote_char = ch;
102
+ result.push(ch);
103
+ } else if ch == '{' || ch == '[' {
104
+ depth += 1;
105
+ result.push(ch);
106
+ } else if ch == '}' || ch == ']' {
107
+ depth -= 1;
108
+ if depth == 0 {
109
+ return Some(result);
110
+ }
111
+ result.push(ch);
112
+ } else {
113
+ result.push(ch);
114
+ }
115
+ } else {
116
+ result.push(ch);
117
+ if ch == quote_char {
118
+ in_string = false;
119
+ }
120
+ }
121
+ }
122
+
123
+ None
124
+ }
125
+
39
126
  /// Parse JavaScript object literal to JSON Value
40
127
  fn parse_object_literal(js: &str) -> Result<Map<String, Value>, String> {
41
128
  // 1. Normalize to valid JSON
@@ -84,14 +171,14 @@ mod tests {
84
171
  #[test]
85
172
  fn extract_simple_state() {
86
173
  let html = r#"<script>vibe({ count: 0 })</script>"#;
87
- let state = StateExtractor::extract_from_html(html).unwrap();
174
+ let state = StateExtractor::extract_from_html(html, &PathBuf::from(".")).unwrap();
88
175
  assert_eq!(state["count"], 0);
89
176
  }
90
177
 
91
178
  #[test]
92
179
  fn extract_nested_state() {
93
180
  let html = r#"vibe({ user: { name: 'John', age: 30 } })"#;
94
- let state = StateExtractor::extract_from_html(html).unwrap();
181
+ let state = StateExtractor::extract_from_html(html, &PathBuf::from(".")).unwrap();
95
182
  assert_eq!(state["user"]["name"], "John");
96
183
  assert_eq!(state["user"]["age"], 30);
97
184
  }
@@ -99,7 +186,7 @@ mod tests {
99
186
  #[test]
100
187
  fn extract_array_state() {
101
188
  let html = r#"vibe({ items: [1, 2, 3] })"#;
102
- let state = StateExtractor::extract_from_html(html).unwrap();
189
+ let state = StateExtractor::extract_from_html(html, &PathBuf::from(".")).unwrap();
103
190
  assert_eq!(state["items"][0], 1);
104
191
  assert_eq!(state["items"][1], 2);
105
192
  assert_eq!(state["items"][2], 3);
@@ -111,7 +198,7 @@ mod tests {
111
198
  <script>vibe({ count: 0 })</script>
112
199
  <script>vibe({ name: 'Test' })</script>
113
200
  "#;
114
- let state = StateExtractor::extract_from_html(html).unwrap();
201
+ let state = StateExtractor::extract_from_html(html, &PathBuf::from(".")).unwrap();
115
202
  assert_eq!(state["count"], 0);
116
203
  assert_eq!(state["name"], "Test");
117
204
  }
@@ -119,14 +206,14 @@ mod tests {
119
206
  #[test]
120
207
  fn extract_with_trailing_comma() {
121
208
  let html = r#"vibe({ count: 0, })"#;
122
- let state = StateExtractor::extract_from_html(html).unwrap();
209
+ let state = StateExtractor::extract_from_html(html, &PathBuf::from(".")).unwrap();
123
210
  assert_eq!(state["count"], 0);
124
211
  }
125
212
 
126
213
  #[test]
127
214
  fn extract_component_state() {
128
215
  let html = r#"component({ title: 'Hello' })"#;
129
- let state = StateExtractor::extract_from_html(html).unwrap();
216
+ let state = StateExtractor::extract_from_html(html, &PathBuf::from(".")).unwrap();
130
217
  assert_eq!(state["title"], "Hello");
131
218
  }
132
219
 
@@ -145,4 +232,32 @@ mod tests {
145
232
  let normalized = StateExtractor::normalize_js_to_json(js);
146
233
  assert!(normalized.contains(r#""John""#));
147
234
  }
235
+
236
+ #[test]
237
+ fn extract_array_of_objects() {
238
+ let html = r#"vibe({
239
+ categories: [
240
+ { name: 'Fruits', items: ['Apple', 'Banana'] },
241
+ { name: 'Veggies', items: ['Carrot'] }
242
+ ]
243
+ })"#;
244
+ let state = StateExtractor::extract_from_html(html, &PathBuf::from(".")).unwrap();
245
+ assert_eq!(state["categories"][0]["name"], "Fruits");
246
+ assert_eq!(state["categories"][0]["items"][0], "Apple");
247
+ assert_eq!(state["categories"][1]["name"], "Veggies");
248
+ }
249
+
250
+ #[test]
251
+ fn extract_balanced_object_simple() {
252
+ let input = "count: 0 }";
253
+ let result = StateExtractor::extract_balanced_object(input).unwrap();
254
+ assert_eq!(result, "count: 0 ");
255
+ }
256
+
257
+ #[test]
258
+ fn extract_balanced_object_nested() {
259
+ let input = "user: { name: 'John', items: [1, 2, 3] } }";
260
+ let result = StateExtractor::extract_balanced_object(input).unwrap();
261
+ assert_eq!(result, "user: { name: 'John', items: [1, 2, 3] } ");
262
+ }
148
263
  }