@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.
@@ -0,0 +1,234 @@
1
+ use html5ever::parse_document;
2
+ use html5ever::tendril::TendrilSink;
3
+ use html5ever::serialize::{serialize, SerializeOpts};
4
+ use markup5ever_rcdom::{RcDom, NodeData, Handle, SerializableHandle};
5
+ use markup5ever::{QualName, LocalName, Namespace};
6
+ use regex::Regex;
7
+ use serde_json::Value;
8
+ use std::collections::HashSet;
9
+ use std::path::PathBuf;
10
+ use std::cell::RefCell;
11
+ use crate::compiler::state_extractor::StateExtractor;
12
+
13
+ pub struct ComponentTagger;
14
+
15
+ pub struct TaggedResult {
16
+ pub html: String,
17
+ pub state: Value,
18
+ }
19
+
20
+ impl ComponentTagger {
21
+ /// Find component wrappers, add deterministic IDs, and structure state
22
+ pub fn tag_components(html: &str, base_path: &PathBuf) -> Result<TaggedResult, String> {
23
+ // Parse HTML
24
+ let dom = parse_document(RcDom::default(), Default::default())
25
+ .from_utf8()
26
+ .read_from(&mut html.as_bytes())
27
+ .map_err(|e| format!("Failed to parse HTML: {:?}", e))?;
28
+
29
+ // Find all component wrappers and extract their state
30
+ let component_counter = RefCell::new(0);
31
+ let component_states = RefCell::new(Vec::new());
32
+ Self::walk_tag_and_extract(&dom.document, &component_counter, &component_states, html);
33
+
34
+ // Serialize back to HTML
35
+ let mut modified_html_bytes = Vec::new();
36
+ serialize(
37
+ &mut modified_html_bytes,
38
+ &SerializableHandle::from(dom.document.clone()),
39
+ SerializeOpts::default()
40
+ ).map_err(|e| format!("Failed to serialize HTML: {:?}", e))?;
41
+
42
+ let modified_html = String::from_utf8(modified_html_bytes)
43
+ .map_err(|e| format!("Failed to convert HTML to UTF-8: {}", e))?;
44
+
45
+ // Extract all state from HTML
46
+ let all_state = StateExtractor::extract_from_html(html, base_path)?;
47
+
48
+ // Build a set of all keys that are in component states
49
+ let mut component_keys = HashSet::new();
50
+ for (_, comp_state) in component_states.borrow().iter() {
51
+ if let Value::Object(comp_map) = comp_state {
52
+ for key in comp_map.keys() {
53
+ component_keys.insert(key.clone());
54
+ }
55
+ }
56
+ }
57
+
58
+ // Filter global state to only include keys NOT in component states
59
+ let mut merged_state = if let Value::Object(all_map) = all_state {
60
+ let mut global_only = serde_json::Map::new();
61
+ for (key, value) in all_map {
62
+ // Only include in global if it's not a component state key
63
+ if !component_keys.contains(&key) {
64
+ global_only.insert(key, value);
65
+ }
66
+ }
67
+ global_only
68
+ } else {
69
+ serde_json::Map::new()
70
+ };
71
+
72
+ // Add component states under their IDs
73
+ for (component_id, comp_state) in component_states.borrow().iter() {
74
+ if let Value::Object(comp_map) = comp_state {
75
+ // Only add if component has non-empty state
76
+ if !comp_map.is_empty() {
77
+ merged_state.insert(component_id.clone(), comp_state.clone());
78
+ }
79
+ }
80
+ }
81
+
82
+ let merged_state = Value::Object(merged_state);
83
+
84
+ Ok(TaggedResult {
85
+ html: modified_html,
86
+ state: merged_state,
87
+ })
88
+ }
89
+
90
+ /// Recursively walk DOM, add data-vibe-component-id, and extract component state
91
+ fn walk_tag_and_extract(
92
+ node: &Handle,
93
+ counter: &RefCell<usize>,
94
+ component_states: &RefCell<Vec<(String, Value)>>,
95
+ original_html: &str,
96
+ ) {
97
+ // Check if this is a component wrapper
98
+ if let NodeData::Element { name, attrs, .. } = &node.data {
99
+ let tag_name = name.local.as_ref();
100
+ let borrowed_attrs = attrs.borrow();
101
+
102
+ // Check if it's <component> (without src) or <div class="component"> (without src)
103
+ let is_component = tag_name == "component" && !Self::has_src_attr(&borrowed_attrs);
104
+ let is_div_component = tag_name == "div"
105
+ && Self::has_class_component(&borrowed_attrs)
106
+ && !Self::has_src_attr(&borrowed_attrs);
107
+
108
+ if is_component || is_div_component {
109
+ drop(borrowed_attrs); // Release borrow before checking innerHTML
110
+
111
+ // First check if this component has state-registering scripts
112
+ // Serialize this node's children to get innerHTML
113
+ let mut inner_html_bytes = Vec::new();
114
+ for child in node.children.borrow().iter() {
115
+ let _ = serialize(
116
+ &mut inner_html_bytes,
117
+ &SerializableHandle::from(child.clone()),
118
+ SerializeOpts::default()
119
+ );
120
+ }
121
+
122
+ if let Ok(inner_html) = String::from_utf8(inner_html_bytes) {
123
+ // Extract state from component's innerHTML
124
+ if let Ok(Value::Object(comp_state)) = StateExtractor::extract_from_html(
125
+ &inner_html,
126
+ &PathBuf::from(".")
127
+ ) {
128
+ // Only tag and register components that have state
129
+ if !comp_state.is_empty() {
130
+ let component_id = format!("_c{}", *counter.borrow());
131
+ *counter.borrow_mut() += 1;
132
+
133
+ // Add data-vibe-component-id attribute
134
+ let mut attrs_mut = attrs.borrow_mut();
135
+ attrs_mut.push(markup5ever::Attribute {
136
+ name: QualName::new(
137
+ None,
138
+ Namespace::from(""),
139
+ LocalName::from("data-vibe-component-id"),
140
+ ),
141
+ value: component_id.clone().into(),
142
+ });
143
+ drop(attrs_mut);
144
+
145
+ // Rewrite this.property to componentId.property in the node's children
146
+ // This allows ValueStamper to properly evaluate component-scoped expressions
147
+ Self::rewrite_this_to_component_id(node, &component_id);
148
+
149
+ component_states.borrow_mut().push((component_id, Value::Object(comp_state)));
150
+ }
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ // Recurse into children
157
+ for child in node.children.borrow().iter() {
158
+ Self::walk_tag_and_extract(child, counter, component_states, original_html);
159
+ }
160
+ }
161
+
162
+ /// Check if element has src attribute
163
+ fn has_src_attr(attrs: &[markup5ever::Attribute]) -> bool {
164
+ attrs.iter().any(|attr| attr.name.local.as_ref() == "src")
165
+ }
166
+
167
+ /// Check if element has class="component"
168
+ fn has_class_component(attrs: &[markup5ever::Attribute]) -> bool {
169
+ attrs.iter().any(|attr| {
170
+ attr.name.local.as_ref() == "class"
171
+ && attr.value.as_ref().split_whitespace().any(|c| c == "component")
172
+ })
173
+ }
174
+
175
+ /// Rewrite this.property to componentId.property in a node's subtree
176
+ fn rewrite_this_to_component_id(node: &Handle, component_id: &str) {
177
+ use regex::Regex;
178
+ let this_regex = Regex::new(r"@\[this\.(\w+)\]").unwrap();
179
+
180
+ // Recursively walk the node and all descendants
181
+ Self::rewrite_node_recursive(node, &this_regex, component_id);
182
+ }
183
+
184
+ /// Recursively rewrite this.property in text nodes and attributes
185
+ fn rewrite_node_recursive(node: &Handle, regex: &Regex, component_id: &str) {
186
+ // Rewrite text content
187
+ if let NodeData::Text { ref contents } = node.data {
188
+ let mut text = contents.borrow_mut();
189
+ let new_text = regex.replace_all(&text, format!("@[{}.$1]", component_id));
190
+ *text = new_text.to_string().into();
191
+ }
192
+
193
+ // Rewrite attributes
194
+ if let NodeData::Element { ref attrs, .. } = node.data {
195
+ let mut attrs_mut = attrs.borrow_mut();
196
+ for attr in attrs_mut.iter_mut() {
197
+ let new_value = regex.replace_all(&attr.value, format!("@[{}.$1]", component_id));
198
+ attr.value = new_value.to_string().into();
199
+ }
200
+ }
201
+
202
+ // Recurse into children
203
+ for child in node.children.borrow().iter() {
204
+ Self::rewrite_node_recursive(child, regex, component_id);
205
+ }
206
+ }
207
+ }
208
+
209
+ #[cfg(test)]
210
+ mod tests {
211
+ use super::*;
212
+
213
+ #[test]
214
+ fn tag_single_component() {
215
+ let html = r#"<!DOCTYPE html><html><body><component><script>component({ count: 0 })</script><div>@[this.count]</div></component></body></html>"#;
216
+
217
+ let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
218
+
219
+ // Should have data-vibe-component-id in output
220
+ assert!(result.html.contains("data-vibe-component-id=\"_c0\""));
221
+ }
222
+
223
+ #[test]
224
+ fn tag_multiple_components() {
225
+ let html = r#"<!DOCTYPE html><html><body><component></component><component></component><component></component></body></html>"#;
226
+
227
+ let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
228
+
229
+ // Should have _c0, _c1, _c2
230
+ assert!(result.html.contains("data-vibe-component-id=\"_c0\""));
231
+ assert!(result.html.contains("data-vibe-component-id=\"_c1\""));
232
+ assert!(result.html.contains("data-vibe-component-id=\"_c2\""));
233
+ }
234
+ }
@@ -0,0 +1,351 @@
1
+ use regex::Regex;
2
+ use serde::{Serialize, Deserialize};
3
+ use std::collections::HashMap;
4
+
5
+ #[derive(Debug, Clone, Serialize, Deserialize)]
6
+ #[serde(rename_all = "camelCase")]
7
+ pub struct CompiledIteration {
8
+ pub item_alias: String,
9
+ pub index_alias: String,
10
+ pub state_path: String,
11
+ pub batch_fn: String,
12
+ }
13
+
14
+ #[derive(Debug, Clone, Serialize, Deserialize)]
15
+ pub struct IterationOptimizations {
16
+ pub iterations: HashMap<String, CompiledIteration>,
17
+ }
18
+
19
+ /// Generate compiled batch functions for iterations in HTML
20
+ pub fn build_iteration_optimizations(html: &str) -> Option<IterationOptimizations> {
21
+ let iterations = extract_and_compile_iterations(html);
22
+
23
+ if iterations.is_empty() {
24
+ None
25
+ } else {
26
+ Some(IterationOptimizations { iterations })
27
+ }
28
+ }
29
+
30
+ /// Extract <!-- each --> blocks and compile them to batch functions
31
+ fn extract_and_compile_iterations(html: &str) -> HashMap<String, CompiledIteration> {
32
+ let mut result = HashMap::new();
33
+
34
+ // Match: <!-- each arrayPath as item, index -->
35
+ let each_re = Regex::new(r"<!--\s*each\s+([^\s]+)\s+as\s+([^\s,]+)(?:\s*,\s*([^\s]+))?\s*-->").unwrap();
36
+
37
+ let mut search_start = 0;
38
+
39
+ while let Some(start_match) = each_re.find_at(html, search_start) {
40
+ let captures = each_re.captures(&html[start_match.start()..]).unwrap();
41
+ let state_path = captures.get(1).unwrap().as_str();
42
+ let item_alias = captures.get(2).unwrap().as_str();
43
+ let index_alias = captures.get(3).map(|m| m.as_str()).unwrap_or("index");
44
+
45
+ let template_start = start_match.end();
46
+
47
+ // Find matching <!-- /each --> using depth counting
48
+ if let Some((end_pos, end_after)) = find_matching_each_end(html, template_start) {
49
+ let template_html = &html[template_start..end_pos];
50
+
51
+ // Skip if this iteration is inside a conditional (parent won't be compiled)
52
+ if is_in_conditional_context(html, start_match.start()) {
53
+ search_start = end_after;
54
+ continue;
55
+ }
56
+
57
+ // Check if template has nested structures (skip if so)
58
+ if has_nested_structures(template_html) {
59
+ search_start = end_after;
60
+ continue;
61
+ }
62
+
63
+ // Generate hash for this template
64
+ let hash = generate_template_hash(template_html);
65
+
66
+ // Compile template to batch function
67
+ let batch_fn = compile_template_to_batch_fn(
68
+ template_html,
69
+ item_alias,
70
+ index_alias,
71
+ );
72
+
73
+ result.insert(hash, CompiledIteration {
74
+ item_alias: item_alias.to_string(),
75
+ index_alias: index_alias.to_string(),
76
+ state_path: state_path.to_string(),
77
+ batch_fn,
78
+ });
79
+
80
+ search_start = end_after;
81
+ } else {
82
+ break;
83
+ }
84
+ }
85
+
86
+ result
87
+ }
88
+
89
+ /// Check if template contains conditionals (we skip these)
90
+ /// Nested iterations are supported, but not if parent has conditionals
91
+ fn has_nested_structures(template: &str) -> bool {
92
+ template.contains("<!-- if")
93
+ }
94
+
95
+ /// Check if we're inside a non-compiled parent iteration
96
+ /// This happens when the template contains conditionals that prevent compilation
97
+ fn is_in_conditional_context(html: &str, pos: usize) -> bool {
98
+ // Look backwards from current position for unclosed conditionals
99
+ let before = &html[..pos];
100
+ let if_count = before.matches("<!-- if").count();
101
+ let endif_count = before.matches("<!-- /if").count();
102
+ if_count > endif_count
103
+ }
104
+
105
+ /// Generate a stable hash for a template
106
+ fn generate_template_hash(template: &str) -> String {
107
+ use std::collections::hash_map::DefaultHasher;
108
+ use std::hash::{Hash, Hasher};
109
+
110
+ let mut hasher = DefaultHasher::new();
111
+ template.trim().hash(&mut hasher);
112
+ let hash = hasher.finish();
113
+ format!("iter_{:x}", hash)
114
+ }
115
+
116
+ /// Compile template HTML to a batch function string
117
+ /// Supports nested <!-- each --> blocks
118
+ fn compile_template_to_batch_fn(
119
+ template: &str,
120
+ item_alias: &str,
121
+ index_alias: &str,
122
+ ) -> String {
123
+ // Process nested iterations first
124
+ let processed_template = process_nested_iterations(template);
125
+
126
+ // Replace @[expr] with ${expr}
127
+ let binding_re = Regex::new(r"@\[([^\]]+)\]").unwrap();
128
+ let with_bindings = binding_re.replace_all(&processed_template, |caps: &regex::Captures| {
129
+ format!("${{{}}}", &caps[1])
130
+ });
131
+
132
+ // Fix boolean attributes: remove ="" from attributes to match CSS selectors
133
+ // Converts: <div attr=""> to <div attr>
134
+ let boolean_attr_re = Regex::new(r#"(\w+)="""#).unwrap();
135
+ let with_boolean_attrs = boolean_attr_re.replace_all(&with_bindings, "$1");
136
+
137
+ // Escape backslashes only (backticks are fine in JSON strings)
138
+ let escaped = with_boolean_attrs
139
+ .replace('\\', "\\\\");
140
+
141
+ // Generate batch function
142
+ format!(
143
+ r#"(arr, $) => {{ let html = ''; const len = arr.length; for (let {index} = 0; {index} < len; {index}++) {{ const {item} = arr[{index}]; html += `{template}`; }} return html; }}"#,
144
+ index = index_alias,
145
+ item = item_alias,
146
+ template = escaped
147
+ )
148
+ }
149
+
150
+ /// Process nested <!-- each --> blocks recursively
151
+ /// Replaces nested iterations with inline loop code
152
+ fn process_nested_iterations(template: &str) -> String {
153
+ let each_re = Regex::new(r"<!--\s*each\s+([^\s]+)\s+as\s+([^\s,]+)(?:\s*,\s*([^\s]+))?\s*-->").unwrap();
154
+ let end_re = Regex::new(r"<!--\s*/each\s*-->").unwrap();
155
+
156
+ let mut result = template.to_string();
157
+ let mut replacements = Vec::new();
158
+
159
+ // Find all nested iterations
160
+ let mut search_start = 0;
161
+ while let Some(start_match) = each_re.find_at(&result, search_start) {
162
+ let captures = each_re.captures(&result[start_match.start()..]).unwrap();
163
+ let array_path = captures.get(1).unwrap().as_str();
164
+ let item_alias = captures.get(2).unwrap().as_str();
165
+ let index_alias = captures.get(3).map(|m| m.as_str()).unwrap_or("index");
166
+
167
+ let template_start = start_match.end();
168
+
169
+ // Find matching <!-- /each --> using depth counting
170
+ if let Some((end_pos, _)) = find_matching_each_end(&result, template_start) {
171
+ let inner_template = &result[template_start..end_pos];
172
+
173
+ // Recursively process inner template
174
+ let processed_inner = process_nested_iterations(inner_template);
175
+
176
+ // For nested iterations, convert to string concatenation
177
+ // Uses JSON string escaping which is valid JavaScript
178
+ let inner_concat_code = convert_to_string_concat(&processed_inner, item_alias, index_alias, array_path);
179
+
180
+ let nested_code = format!("${{(() => {{ let inner = ''; const len_{idx} = {arr}.length; for (let {idx} = 0; {idx} < len_{idx}; {idx}++) {{ const {item} = {arr}[{idx}]; {code} }} return inner; }})()}}",
181
+ idx = index_alias,
182
+ arr = array_path,
183
+ item = item_alias,
184
+ code = inner_concat_code
185
+ );
186
+
187
+ // Store replacement (from start to end including comments)
188
+ let end_match = end_re.find_at(&result, end_pos).unwrap();
189
+ replacements.push((start_match.start(), end_match.end(), nested_code));
190
+
191
+ search_start = end_match.end();
192
+ } else {
193
+ break;
194
+ }
195
+ }
196
+
197
+ // Apply replacements in reverse order to maintain positions
198
+ for (start, end, replacement) in replacements.iter().rev() {
199
+ result.replace_range(*start..*end, replacement);
200
+ }
201
+
202
+ result
203
+ }
204
+
205
+ /// Convert template to string concatenation code using template literals
206
+ /// Parses @[expr] and converts to: inner += `text${expr}more text`;
207
+ /// Template literals properly handle newlines without escaping
208
+ /// NOTE: Backslashes and ${ need escaping, but backticks don't (they'll be in JSON)
209
+ fn convert_to_string_concat(template: &str, _item_alias: &str, _index_alias: &str, _array_path: &str) -> String {
210
+ let binding_re = Regex::new(r"@\[([^\]]+)\]").unwrap();
211
+
212
+ // Build template literal with ${} expressions
213
+ let mut result = String::from("inner += `");
214
+ let mut last_end = 0;
215
+
216
+ for cap in binding_re.captures_iter(template) {
217
+ let match_start = cap.get(0).unwrap().start();
218
+ let match_end = cap.get(0).unwrap().end();
219
+ let expr = &cap[1];
220
+
221
+ // Add text before this binding
222
+ if match_start > last_end {
223
+ let text = &template[last_end..match_start];
224
+ // For template literals: only escape backslashes and ${ sequences
225
+ // Don't escape backticks - they're fine in JSON and we want them as-is
226
+ let escaped = text
227
+ .replace('\\', "\\\\") // Escape backslashes
228
+ .replace("${", "\\${"); // Escape template literal expression markers
229
+ result.push_str(&escaped);
230
+ }
231
+
232
+ // Add expression
233
+ result.push_str("${");
234
+ result.push_str(expr);
235
+ result.push_str("}");
236
+
237
+ last_end = match_end;
238
+ }
239
+
240
+ // Add remaining text
241
+ if last_end < template.len() {
242
+ let text = &template[last_end..];
243
+ let escaped = text
244
+ .replace('\\', "\\\\")
245
+ .replace("${", "\\${");
246
+ result.push_str(&escaped);
247
+ }
248
+
249
+ result.push_str("`;");
250
+ result
251
+ }
252
+
253
+ /// Find matching <!-- /each --> comment using depth counting
254
+ /// Returns (position_before_comment, position_after_comment)
255
+ fn find_matching_each_end(html: &str, start_pos: usize) -> Option<(usize, usize)> {
256
+ let each_re = Regex::new(r"<!--\s*each\s+").unwrap();
257
+ let end_re = Regex::new(r"<!--\s*/each\s*-->").unwrap();
258
+
259
+ let mut depth = 1;
260
+ let mut search_pos = start_pos;
261
+
262
+ while depth > 0 {
263
+ let next_start = each_re.find_at(html, search_pos);
264
+ let next_end = end_re.find_at(html, search_pos);
265
+
266
+ match (next_start, next_end) {
267
+ (Some(start_match), Some(end_match)) if start_match.start() < end_match.start() => {
268
+ // Found nested <!-- each --> before <!-- /each -->
269
+ depth += 1;
270
+ search_pos = start_match.end();
271
+ }
272
+ (_, Some(end_match)) => {
273
+ // Found <!-- /each -->
274
+ depth -= 1;
275
+ if depth == 0 {
276
+ return Some((end_match.start(), end_match.end()));
277
+ }
278
+ search_pos = end_match.end();
279
+ }
280
+ _ => return None, // No matching end found
281
+ }
282
+ }
283
+
284
+ None
285
+ }
286
+
287
+ #[cfg(test)]
288
+ mod tests {
289
+ use super::*;
290
+
291
+ #[test]
292
+ fn test_simple_iteration() {
293
+ let html = r#"
294
+ <!-- each items as item -->
295
+ <li>@[item.name]</li>
296
+ <!-- /each -->
297
+ "#;
298
+
299
+ let opts = build_iteration_optimizations(html);
300
+ assert!(opts.is_some());
301
+
302
+ let iterations = &opts.unwrap().iterations;
303
+ assert_eq!(iterations.len(), 1);
304
+
305
+ let (_, compiled) = iterations.iter().next().unwrap();
306
+ assert_eq!(compiled.item_alias, "item");
307
+ assert_eq!(compiled.index_alias, "index");
308
+ assert_eq!(compiled.state_path, "items");
309
+ assert!(compiled.batch_fn.contains("html += `"));
310
+ }
311
+
312
+ #[test]
313
+ fn test_iteration_with_index() {
314
+ let html = r#"
315
+ <!-- each items as item, idx -->
316
+ <li>[@[idx]] @[item.name]</li>
317
+ <!-- /each -->
318
+ "#;
319
+
320
+ let opts = build_iteration_optimizations(html);
321
+ let iterations = &opts.unwrap().iterations;
322
+ let (_, compiled) = iterations.iter().next().unwrap();
323
+ assert_eq!(compiled.index_alias, "idx");
324
+ }
325
+
326
+ #[test]
327
+ fn test_nested_iteration_compiled() {
328
+ let html = r#"
329
+ <!-- each categories as cat -->
330
+ <div>
331
+ <!-- each cat.items as item -->
332
+ <span>@[item]</span>
333
+ <!-- /each -->
334
+ </div>
335
+ <!-- /each -->
336
+ "#;
337
+
338
+ let opts = build_iteration_optimizations(html);
339
+ // Should compile nested iterations
340
+ assert!(opts.is_some());
341
+
342
+ let iterations = &opts.unwrap().iterations;
343
+ assert_eq!(iterations.len(), 1);
344
+
345
+ let (_, compiled) = iterations.iter().next().unwrap();
346
+ // Should contain nested loop code with template literals
347
+ assert!(compiled.batch_fn.contains("cat.items"));
348
+ assert!(compiled.batch_fn.contains("let inner = ''"));
349
+ assert!(compiled.batch_fn.contains("inner += `"));
350
+ }
351
+ }