@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.
@@ -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
+ }
@@ -1,4 +1,7 @@
1
1
  pub mod compile;
2
+ mod state_extractor;
3
+ mod manifest_builder;
4
+ mod value_stamper;
2
5
 
3
6
  pub use compile::Compiler;
4
7
  #[allow(unused_imports)]
@@ -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
+ }