@ape-egg/vibe 1.2.0 → 1.3.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 +62 -0
- package/README.md +1 -1
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/src/compiler/compile.rs +204 -51
- package/compiler/src/compiler/manifest_builder.rs +432 -0
- package/compiler/src/compiler/mod.rs +3 -0
- package/compiler/src/compiler/state_extractor.rs +148 -0
- package/compiler/src/compiler/value_stamper.rs +222 -0
- package/compiler/src/config.rs +0 -5
- package/compiler/src/main.rs +45 -20
- package/compiler/src/parser/html.rs +22 -8
- package/index.js +30 -86
- package/package.json +1 -1
- package/runtime/affected.js +12 -6
- package/runtime/cleanup.js +45 -24
- package/runtime/component-state.js +1 -1
- package/runtime/component.js +20 -13
- package/runtime/constants.js +41 -10
- package/runtime/debug.js +1 -0
- package/runtime/hydrate.js +6 -1
- package/runtime/hyperspeed.js +425 -0
- package/runtime/index.js +236 -49
- package/runtime/iterate.js +3 -0
- package/runtime/parse.js +21 -29
- package/runtime/scope.js +5 -25
- package/runtime/utils.js +4 -13
- package/vibe.css +4 -2
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
use html5ever::parse_document;
|
|
2
|
+
use html5ever::tendril::TendrilSink;
|
|
3
|
+
use markup5ever_rcdom::{RcDom, NodeData, Handle};
|
|
4
|
+
use regex::Regex;
|
|
5
|
+
use serde::{Serialize, Serializer};
|
|
6
|
+
use serde_json::Value;
|
|
7
|
+
use std::collections::HashMap;
|
|
8
|
+
|
|
9
|
+
pub struct ManifestBuilder {
|
|
10
|
+
binding_regex: Regex,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
impl ManifestBuilder {
|
|
14
|
+
pub fn new() -> Self {
|
|
15
|
+
Self {
|
|
16
|
+
binding_regex: Regex::new(r"@\[((?:[^\[\]]|\[[^\]]*\])+)\]").unwrap(),
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
pub fn build_from_html(&self, html: &str, _state: &Value) -> Result<ManifestNode, String> {
|
|
21
|
+
// Parse HTML
|
|
22
|
+
let dom = parse_document(RcDom::default(), Default::default())
|
|
23
|
+
.from_utf8()
|
|
24
|
+
.read_from(&mut html.as_bytes())
|
|
25
|
+
.map_err(|e| format!("Failed to parse HTML: {:?}", e))?;
|
|
26
|
+
|
|
27
|
+
// Walk tree starting from document root
|
|
28
|
+
let root_node = self.walk_node(&dom.document, &mut 0);
|
|
29
|
+
|
|
30
|
+
Ok(root_node)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
fn walk_node(&self, node: &Handle, _counter: &mut usize) -> ManifestNode {
|
|
34
|
+
self.walk_node_impl(node, &node.children.borrow())
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
fn walk_node_impl(&self, node: &Handle, siblings: &[Handle]) -> ManifestNode {
|
|
38
|
+
let mut manifest_node = ManifestNode {
|
|
39
|
+
element: None,
|
|
40
|
+
parsed: vec![],
|
|
41
|
+
children: HashMap::new(),
|
|
42
|
+
hyperspeed_restoration: None,
|
|
43
|
+
node_type: None,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
match &node.data {
|
|
47
|
+
NodeData::Text { contents } => {
|
|
48
|
+
let text = contents.borrow().to_string();
|
|
49
|
+
|
|
50
|
+
// Check for @[...] bindings
|
|
51
|
+
if self.binding_regex.is_match(&text) {
|
|
52
|
+
let parsed = self.split_by_bindings(&text);
|
|
53
|
+
manifest_node.hyperspeed_restoration = Some(RestorationData {
|
|
54
|
+
parsed: Some(parsed),
|
|
55
|
+
..Default::default()
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
NodeData::Element { name, attrs, .. } => {
|
|
61
|
+
let _tag_name = name.local.to_string();
|
|
62
|
+
|
|
63
|
+
// Check attribute bindings
|
|
64
|
+
let mut attr_bindings = HashMap::new();
|
|
65
|
+
for attr in attrs.borrow().iter() {
|
|
66
|
+
let attr_name = attr.name.local.to_string();
|
|
67
|
+
let attr_value = attr.value.to_string();
|
|
68
|
+
|
|
69
|
+
if self.binding_regex.is_match(&attr_value) {
|
|
70
|
+
attr_bindings.insert(attr_name, attr_value);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if !attr_bindings.is_empty() {
|
|
75
|
+
manifest_node.hyperspeed_restoration = Some(RestorationData {
|
|
76
|
+
attributes: Some(attr_bindings),
|
|
77
|
+
..Default::default()
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
NodeData::Comment { contents } => {
|
|
83
|
+
let comment = contents.to_string();
|
|
84
|
+
let trimmed = comment.trim();
|
|
85
|
+
|
|
86
|
+
// Check for iteration: <!-- each items as item -->
|
|
87
|
+
if trimmed.starts_with("each ") {
|
|
88
|
+
manifest_node.node_type = Some("iteration".to_string());
|
|
89
|
+
|
|
90
|
+
// Find node index in siblings
|
|
91
|
+
let node_idx = siblings.iter().position(|n| std::ptr::eq(n as *const _, node as *const _));
|
|
92
|
+
|
|
93
|
+
// Extract template if we can find this node in siblings
|
|
94
|
+
let template = if let Some(idx) = node_idx {
|
|
95
|
+
self.extract_template_from_siblings(siblings, idx, "each ", "/each")
|
|
96
|
+
} else {
|
|
97
|
+
String::new()
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
manifest_node.hyperspeed_restoration = Some(RestorationData {
|
|
101
|
+
template: Some(template),
|
|
102
|
+
..Default::default()
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Check for conditionals: <!-- if condition -->
|
|
107
|
+
if trimmed.starts_with("if ") {
|
|
108
|
+
manifest_node.node_type = Some("conditional".to_string());
|
|
109
|
+
|
|
110
|
+
let node_idx = siblings.iter().position(|n| std::ptr::eq(n as *const _, node as *const _));
|
|
111
|
+
|
|
112
|
+
let template = if let Some(idx) = node_idx {
|
|
113
|
+
self.extract_template_from_siblings(siblings, idx, "if ", "/if")
|
|
114
|
+
} else {
|
|
115
|
+
String::new()
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
manifest_node.hyperspeed_restoration = Some(RestorationData {
|
|
119
|
+
template: Some(template),
|
|
120
|
+
..Default::default()
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
_ => {}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Recursively process children using local indices (to match runtime key generation)
|
|
129
|
+
let children = node.children.borrow();
|
|
130
|
+
let mut skip_indices = std::collections::HashSet::new();
|
|
131
|
+
|
|
132
|
+
// First pass: identify indices to skip (iteration/conditional template content)
|
|
133
|
+
for (i, child) in children.iter().enumerate() {
|
|
134
|
+
if let NodeData::Comment { contents } = &child.data {
|
|
135
|
+
let trimmed = contents.trim();
|
|
136
|
+
|
|
137
|
+
// Find iteration blocks and mark template indices to skip
|
|
138
|
+
if trimmed.starts_with("each ") {
|
|
139
|
+
if let Some(end_idx) = self.find_matching_end(&children, i, "each ", "/each") {
|
|
140
|
+
// Skip all nodes between start and end comments (including end comment)
|
|
141
|
+
for j in (i + 1)..=end_idx {
|
|
142
|
+
skip_indices.insert(j);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Find conditional blocks and mark template indices to skip
|
|
148
|
+
if trimmed.starts_with("if ") {
|
|
149
|
+
if let Some(end_idx) = self.find_matching_end(&children, i, "if ", "/if") {
|
|
150
|
+
for j in (i + 1)..=end_idx {
|
|
151
|
+
skip_indices.insert(j);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Second pass: process children that aren't skipped
|
|
159
|
+
for (local_index, child) in children.iter().enumerate() {
|
|
160
|
+
if skip_indices.contains(&local_index) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let child_key = self.generate_child_key(child, &local_index);
|
|
165
|
+
manifest_node.children.insert(
|
|
166
|
+
child_key.clone(),
|
|
167
|
+
self.walk_node_impl(child, &children)
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
manifest_node
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/// Split text by @[...] bindings: "Hello @[name]!" -> ["Hello ", "@[name]", "!"]
|
|
175
|
+
fn split_by_bindings(&self, text: &str) -> Vec<String> {
|
|
176
|
+
let mut result = vec![];
|
|
177
|
+
let mut last_end = 0;
|
|
178
|
+
|
|
179
|
+
for mat in self.binding_regex.find_iter(text) {
|
|
180
|
+
// Add text before match
|
|
181
|
+
if mat.start() > last_end {
|
|
182
|
+
result.push(text[last_end..mat.start()].to_string());
|
|
183
|
+
}
|
|
184
|
+
// Add binding
|
|
185
|
+
result.push(mat.as_str().to_string());
|
|
186
|
+
last_end = mat.end();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Add remaining text
|
|
190
|
+
if last_end < text.len() {
|
|
191
|
+
result.push(text[last_end..].to_string());
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
result
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/// Generate child key: "text_0", "div_1", etc.
|
|
198
|
+
fn generate_child_key(&self, node: &Handle, index: &usize) -> String {
|
|
199
|
+
match &node.data {
|
|
200
|
+
NodeData::Text { .. } => format!("text_{}", index),
|
|
201
|
+
NodeData::Element { name, .. } => {
|
|
202
|
+
format!("{}_{}", name.local.to_lowercase(), index)
|
|
203
|
+
}
|
|
204
|
+
NodeData::Comment { contents } => {
|
|
205
|
+
let trimmed = contents.trim();
|
|
206
|
+
if trimmed.starts_with("each ") {
|
|
207
|
+
format!("iteration_{}", index)
|
|
208
|
+
} else if trimmed.starts_with("if ") {
|
|
209
|
+
format!("conditional_{}", index)
|
|
210
|
+
} else {
|
|
211
|
+
format!("comment_{}", index)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
_ => format!("node_{}", index),
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/// Find the index of matching end comment
|
|
219
|
+
fn find_matching_end(&self, siblings: &[Handle], start_idx: usize, start_marker: &str, end_marker: &str) -> Option<usize> {
|
|
220
|
+
let mut depth = 0;
|
|
221
|
+
|
|
222
|
+
for (idx, sibling) in siblings.iter().enumerate().skip(start_idx + 1) {
|
|
223
|
+
if let NodeData::Comment { contents } = &sibling.data {
|
|
224
|
+
let trimmed = contents.trim();
|
|
225
|
+
if trimmed.starts_with(start_marker) {
|
|
226
|
+
depth += 1;
|
|
227
|
+
} else if trimmed == end_marker {
|
|
228
|
+
if depth == 0 {
|
|
229
|
+
return Some(idx);
|
|
230
|
+
} else {
|
|
231
|
+
depth -= 1;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
None
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/// Extract template between start and end comments (generic for iterations and conditionals)
|
|
241
|
+
fn extract_template_from_siblings(&self, siblings: &[Handle], start_idx: usize, start_marker: &str, end_marker: &str) -> String {
|
|
242
|
+
// Find matching end comment
|
|
243
|
+
let mut depth = 0;
|
|
244
|
+
let mut end_idx = None;
|
|
245
|
+
|
|
246
|
+
for (idx, sibling) in siblings.iter().enumerate().skip(start_idx + 1) {
|
|
247
|
+
if let NodeData::Comment { contents } = &sibling.data {
|
|
248
|
+
let trimmed = contents.trim();
|
|
249
|
+
if trimmed.starts_with(start_marker) {
|
|
250
|
+
depth += 1;
|
|
251
|
+
} else if trimmed == end_marker {
|
|
252
|
+
if depth == 0 {
|
|
253
|
+
end_idx = Some(idx);
|
|
254
|
+
break;
|
|
255
|
+
} else {
|
|
256
|
+
depth -= 1;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Extract template HTML between start and end
|
|
263
|
+
if let Some(end) = end_idx {
|
|
264
|
+
let template_nodes: Vec<_> = siblings.iter()
|
|
265
|
+
.skip(start_idx + 1)
|
|
266
|
+
.take(end - start_idx - 1)
|
|
267
|
+
.filter(|node| {
|
|
268
|
+
// Skip whitespace-only text nodes
|
|
269
|
+
if let NodeData::Text { contents } = &node.data {
|
|
270
|
+
!contents.borrow().trim().is_empty()
|
|
271
|
+
} else {
|
|
272
|
+
true // Keep all non-text nodes
|
|
273
|
+
}
|
|
274
|
+
})
|
|
275
|
+
.collect();
|
|
276
|
+
|
|
277
|
+
return self.serialize_nodes(&template_nodes);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
String::new()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/// Serialize nodes to HTML string
|
|
284
|
+
fn serialize_nodes(&self, nodes: &[&Handle]) -> String {
|
|
285
|
+
use html5ever::serialize::{serialize, SerializeOpts, TraversalScope};
|
|
286
|
+
use markup5ever_rcdom::SerializableHandle;
|
|
287
|
+
|
|
288
|
+
let mut html = String::new();
|
|
289
|
+
|
|
290
|
+
for node in nodes.iter() {
|
|
291
|
+
let mut bytes = Vec::new();
|
|
292
|
+
|
|
293
|
+
// Serialize with IncludeNode to include the element tags
|
|
294
|
+
let opts = SerializeOpts {
|
|
295
|
+
traversal_scope: TraversalScope::IncludeNode,
|
|
296
|
+
..Default::default()
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
let serializable = SerializableHandle::from((*node).clone());
|
|
300
|
+
serialize(&mut bytes, &serializable, opts).ok();
|
|
301
|
+
|
|
302
|
+
if let Ok(node_html) = String::from_utf8(bytes) {
|
|
303
|
+
html.push_str(&node_html);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
html
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
#[derive(Debug)]
|
|
312
|
+
pub struct ManifestNode {
|
|
313
|
+
element: Option<()>, // Always null in static manifest
|
|
314
|
+
parsed: Vec<String>,
|
|
315
|
+
children: HashMap<String, ManifestNode>,
|
|
316
|
+
|
|
317
|
+
#[allow(dead_code)]
|
|
318
|
+
hyperspeed_restoration: Option<RestorationData>,
|
|
319
|
+
|
|
320
|
+
#[allow(dead_code)]
|
|
321
|
+
node_type: Option<String>,
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Custom Serialize implementation to control output format
|
|
325
|
+
impl Serialize for ManifestNode {
|
|
326
|
+
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
327
|
+
where
|
|
328
|
+
S: Serializer,
|
|
329
|
+
{
|
|
330
|
+
use serde::ser::SerializeMap;
|
|
331
|
+
|
|
332
|
+
let mut map = serializer.serialize_map(None)?;
|
|
333
|
+
|
|
334
|
+
// Always include element (null)
|
|
335
|
+
map.serialize_entry("element", &self.element)?;
|
|
336
|
+
|
|
337
|
+
// Always include parsed (empty array if no data)
|
|
338
|
+
map.serialize_entry("parsed", &self.parsed)?;
|
|
339
|
+
|
|
340
|
+
// Always include children
|
|
341
|
+
map.serialize_entry("children", &self.children)?;
|
|
342
|
+
|
|
343
|
+
// Optional: hyperspeedRestoration
|
|
344
|
+
if let Some(ref restoration) = self.hyperspeed_restoration {
|
|
345
|
+
map.serialize_entry("hyperspeedRestoration", restoration)?;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Optional: type
|
|
349
|
+
if let Some(ref node_type) = self.node_type {
|
|
350
|
+
map.serialize_entry("type", node_type)?;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
map.end()
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
#[derive(Debug, Serialize, Default)]
|
|
358
|
+
pub struct RestorationData {
|
|
359
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
360
|
+
pub parsed: Option<Vec<String>>,
|
|
361
|
+
|
|
362
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
363
|
+
pub attributes: Option<HashMap<String, String>>,
|
|
364
|
+
|
|
365
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
366
|
+
pub template: Option<String>,
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
#[cfg(test)]
|
|
370
|
+
mod tests {
|
|
371
|
+
use super::*;
|
|
372
|
+
use serde_json::json;
|
|
373
|
+
|
|
374
|
+
#[test]
|
|
375
|
+
fn build_simple_text_binding() {
|
|
376
|
+
let html = r#"<div>Hello @[name]</div>"#;
|
|
377
|
+
let state = json!({ "name": "World" });
|
|
378
|
+
let builder = ManifestBuilder::new();
|
|
379
|
+
let manifest = builder.build_from_html(html, &state).unwrap();
|
|
380
|
+
|
|
381
|
+
// Should have children
|
|
382
|
+
assert!(!manifest.children.is_empty());
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
#[test]
|
|
386
|
+
fn build_attribute_binding() {
|
|
387
|
+
let html = r#"<input value="@[firstName]">"#;
|
|
388
|
+
let state = json!({ "firstName": "John" });
|
|
389
|
+
let builder = ManifestBuilder::new();
|
|
390
|
+
let manifest = builder.build_from_html(html, &state).unwrap();
|
|
391
|
+
|
|
392
|
+
// Should have children
|
|
393
|
+
assert!(!manifest.children.is_empty());
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
#[test]
|
|
397
|
+
fn build_iteration_with_template() {
|
|
398
|
+
let html = r#"<body><!-- each items as item --><div>@[item]</div><!-- /each --></body>"#;
|
|
399
|
+
let state = json!({ "items": [1, 2, 3] });
|
|
400
|
+
let builder = ManifestBuilder::new();
|
|
401
|
+
let manifest = builder.build_from_html(html, &state).unwrap();
|
|
402
|
+
|
|
403
|
+
// Serialize to JSON to inspect
|
|
404
|
+
let json = serde_json::to_string_pretty(&manifest).unwrap();
|
|
405
|
+
eprintln!("Manifest JSON:\n{}", json);
|
|
406
|
+
|
|
407
|
+
// Find iteration node
|
|
408
|
+
// Note: might be nested under body
|
|
409
|
+
assert!(!manifest.children.is_empty());
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
#[test]
|
|
413
|
+
fn split_bindings_simple() {
|
|
414
|
+
let builder = ManifestBuilder::new();
|
|
415
|
+
let result = builder.split_by_bindings("Hello @[name]!");
|
|
416
|
+
assert_eq!(result, vec!["Hello ", "@[name]", "!"]);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
#[test]
|
|
420
|
+
fn split_bindings_multiple() {
|
|
421
|
+
let builder = ManifestBuilder::new();
|
|
422
|
+
let result = builder.split_by_bindings("@[firstName] @[lastName]");
|
|
423
|
+
assert_eq!(result, vec!["@[firstName]", " ", "@[lastName]"]);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
#[test]
|
|
427
|
+
fn split_bindings_no_match() {
|
|
428
|
+
let builder = ManifestBuilder::new();
|
|
429
|
+
let result = builder.split_by_bindings("Hello World");
|
|
430
|
+
assert_eq!(result, vec!["Hello World"]);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
use regex::Regex;
|
|
2
|
+
use serde_json::{Value, Map};
|
|
3
|
+
|
|
4
|
+
pub struct StateExtractor;
|
|
5
|
+
|
|
6
|
+
impl StateExtractor {
|
|
7
|
+
/// Extract state from all vibe() and component() calls in HTML
|
|
8
|
+
pub fn extract_from_html(html: &str) -> Result<Value, String> {
|
|
9
|
+
let mut merged_state = Map::new();
|
|
10
|
+
|
|
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))?;
|
|
15
|
+
|
|
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);
|
|
20
|
+
}
|
|
21
|
+
|
|
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))?;
|
|
26
|
+
|
|
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)?;
|
|
30
|
+
|
|
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);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
Ok(Value::Object(merged_state))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Parse JavaScript object literal to JSON Value
|
|
40
|
+
fn parse_object_literal(js: &str) -> Result<Map<String, Value>, String> {
|
|
41
|
+
// 1. Normalize to valid JSON
|
|
42
|
+
let normalized = Self::normalize_js_to_json(js);
|
|
43
|
+
|
|
44
|
+
// 2. Parse as JSON
|
|
45
|
+
serde_json::from_str::<Value>(&normalized)
|
|
46
|
+
.map_err(|e| format!("Failed to parse state: {}", e))?
|
|
47
|
+
.as_object()
|
|
48
|
+
.cloned()
|
|
49
|
+
.ok_or_else(|| "Expected object".to_string())
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Convert JS object literal syntax to valid JSON
|
|
53
|
+
fn normalize_js_to_json(js: &str) -> String {
|
|
54
|
+
let mut json = js.trim().to_string();
|
|
55
|
+
|
|
56
|
+
// Convert single quotes to double quotes (before key quoting to avoid conflicts)
|
|
57
|
+
json = json.replace("'", "\"");
|
|
58
|
+
|
|
59
|
+
// Quote unquoted keys: name: value -> "name": value
|
|
60
|
+
// Match at start of string or after { , or whitespace
|
|
61
|
+
let key_regex = Regex::new(r#"(^|[\{,]\s*)(\w+)(\s*:)"#).unwrap();
|
|
62
|
+
json = key_regex.replace_all(&json, r#"$1"$2"$3"#).to_string();
|
|
63
|
+
|
|
64
|
+
// Wrap in braces
|
|
65
|
+
let wrapped = format!("{{{}}}", json);
|
|
66
|
+
|
|
67
|
+
// Remove trailing commas before } and ] (after wrapping)
|
|
68
|
+
let trailing_comma_regex = Regex::new(r#",(\s*[}\]])"#).unwrap();
|
|
69
|
+
trailing_comma_regex.replace_all(&wrapped, "$1").to_string()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// Merge source into target (mutates target)
|
|
73
|
+
fn merge_into(target: &mut Map<String, Value>, source: Map<String, Value>) {
|
|
74
|
+
for (key, value) in source {
|
|
75
|
+
target.insert(key, value);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#[cfg(test)]
|
|
81
|
+
mod tests {
|
|
82
|
+
use super::*;
|
|
83
|
+
|
|
84
|
+
#[test]
|
|
85
|
+
fn extract_simple_state() {
|
|
86
|
+
let html = r#"<script>vibe({ count: 0 })</script>"#;
|
|
87
|
+
let state = StateExtractor::extract_from_html(html).unwrap();
|
|
88
|
+
assert_eq!(state["count"], 0);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#[test]
|
|
92
|
+
fn extract_nested_state() {
|
|
93
|
+
let html = r#"vibe({ user: { name: 'John', age: 30 } })"#;
|
|
94
|
+
let state = StateExtractor::extract_from_html(html).unwrap();
|
|
95
|
+
assert_eq!(state["user"]["name"], "John");
|
|
96
|
+
assert_eq!(state["user"]["age"], 30);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#[test]
|
|
100
|
+
fn extract_array_state() {
|
|
101
|
+
let html = r#"vibe({ items: [1, 2, 3] })"#;
|
|
102
|
+
let state = StateExtractor::extract_from_html(html).unwrap();
|
|
103
|
+
assert_eq!(state["items"][0], 1);
|
|
104
|
+
assert_eq!(state["items"][1], 2);
|
|
105
|
+
assert_eq!(state["items"][2], 3);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
#[test]
|
|
109
|
+
fn extract_multiple_calls() {
|
|
110
|
+
let html = r#"
|
|
111
|
+
<script>vibe({ count: 0 })</script>
|
|
112
|
+
<script>vibe({ name: 'Test' })</script>
|
|
113
|
+
"#;
|
|
114
|
+
let state = StateExtractor::extract_from_html(html).unwrap();
|
|
115
|
+
assert_eq!(state["count"], 0);
|
|
116
|
+
assert_eq!(state["name"], "Test");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#[test]
|
|
120
|
+
fn extract_with_trailing_comma() {
|
|
121
|
+
let html = r#"vibe({ count: 0, })"#;
|
|
122
|
+
let state = StateExtractor::extract_from_html(html).unwrap();
|
|
123
|
+
assert_eq!(state["count"], 0);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#[test]
|
|
127
|
+
fn extract_component_state() {
|
|
128
|
+
let html = r#"component({ title: 'Hello' })"#;
|
|
129
|
+
let state = StateExtractor::extract_from_html(html).unwrap();
|
|
130
|
+
assert_eq!(state["title"], "Hello");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#[test]
|
|
134
|
+
fn normalize_unquoted_keys() {
|
|
135
|
+
let js = "count: 0, name: 'Test'";
|
|
136
|
+
let normalized = StateExtractor::normalize_js_to_json(js);
|
|
137
|
+
eprintln!("Normalized: {}", normalized);
|
|
138
|
+
assert!(normalized.contains(r#""count""#));
|
|
139
|
+
assert!(normalized.contains(r#""name""#));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#[test]
|
|
143
|
+
fn normalize_single_quotes() {
|
|
144
|
+
let js = "name: 'John'";
|
|
145
|
+
let normalized = StateExtractor::normalize_js_to_json(js);
|
|
146
|
+
assert!(normalized.contains(r#""John""#));
|
|
147
|
+
}
|
|
148
|
+
}
|