@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
|
@@ -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: ®ex::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
|
+
}
|