@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.
- package/CHANGELOG.md +177 -0
- package/README.md +97 -0
- package/boot.js +0 -1
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/src/Cargo.lock +719 -40
- package/compiler/src/Cargo.toml +11 -2
- package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +241 -0
- package/compiler/src/compiler/compile.rs +552 -175
- package/compiler/src/compiler/component_tagger.rs +234 -0
- package/compiler/src/compiler/iteration_optimizer.rs +351 -0
- package/compiler/src/compiler/js_analyzer.rs +572 -0
- package/compiler/src/compiler/manifest_builder.rs +251 -26
- package/compiler/src/compiler/mod.rs +5 -1
- package/compiler/src/compiler/state_extractor.rs +140 -25
- package/compiler/src/compiler/value_stamper.rs +579 -88
- package/compiler/src/compiler/watcher.rs +579 -0
- package/compiler/src/config.rs +51 -8
- package/compiler/src/main.rs +41 -28
- package/compiler/src/parser/html.rs +229 -118
- package/component.js +23 -11
- package/package.json +1 -1
- package/runtime/cleanup.js +4 -4
- package/runtime/component.js +98 -21
- package/runtime/conditionals.js +2 -2
- package/runtime/constants.js +2 -1
- package/runtime/index.js +152 -30
- package/runtime/iterate.js +27 -5
- package/runtime/parse.js +2 -1
- package/runtime/pre-compiled-iterations.js +153 -0
- package/runtime/{hyperspeed.js → pre-compiled-manifest.js} +204 -132
- package/runtime/utils.js +2 -1
- package/test-results/.last-run.json +4 -0
- package/vibe.css +19 -0
- package/runtime/component-state.js +0 -63
|
@@ -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:
|
|
42
|
-
|
|
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.
|
|
54
|
-
|
|
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 =
|
|
65
|
-
|
|
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
|
-
|
|
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.
|
|
76
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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:
|
|
456
|
+
children: BTreeMap<String, ManifestNode>,
|
|
316
457
|
|
|
317
458
|
#[allow(dead_code)]
|
|
318
|
-
|
|
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:
|
|
344
|
-
if let Some(ref
|
|
345
|
-
map.serialize_entry("
|
|
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<
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
17
|
-
let
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
38
|
+
if merged_state.is_empty() {
|
|
39
|
+
None
|
|
40
|
+
} else {
|
|
41
|
+
Some(Value::Object(merged_state))
|
|
42
|
+
}
|
|
43
|
+
}
|
|
26
44
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
}
|