@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
|
@@ -1,126 +1,568 @@
|
|
|
1
1
|
use regex::{Regex, Captures};
|
|
2
2
|
use serde_json::Value;
|
|
3
|
+
use rquickjs::{Context, Runtime};
|
|
4
|
+
use std::collections::HashSet;
|
|
5
|
+
|
|
6
|
+
// Elements that should not have reactive bindings processed
|
|
7
|
+
// Matches runtime/constants.js NON_REACTIVE_ELEMENTS
|
|
8
|
+
const NON_REACTIVE_ELEMENTS: &[&str] = &["script", "head", "pre"];
|
|
9
|
+
|
|
10
|
+
// Attributes where the string value is meaningful (should NOT be removed when falsy)
|
|
11
|
+
// All other attributes are treated as boolean-like (removed when falsy)
|
|
12
|
+
// Matches runtime/constants.js VALUE_ATTRS
|
|
13
|
+
const VALUE_ATTRS: &[&str] = &[
|
|
14
|
+
// Global attributes
|
|
15
|
+
"class", "style", "id", "title", "lang", "dir", "tabindex", "accesskey",
|
|
16
|
+
"slot", "part", "is", "nonce", "popover", "anchor",
|
|
17
|
+
// Enumerated attributes
|
|
18
|
+
"contenteditable", "draggable", "spellcheck", "translate", "autocapitalize",
|
|
19
|
+
"inputmode", "enterkeyhint", "virtualkeyboardpolicy",
|
|
20
|
+
// URLs and sources
|
|
21
|
+
"href", "src", "action", "cite", "data", "poster", "srcset", "imagesrcset",
|
|
22
|
+
"formaction", "ping", "usemap", "manifest", "codebase",
|
|
23
|
+
// Form attributes
|
|
24
|
+
"name", "type", "value", "placeholder", "pattern", "min", "max", "step",
|
|
25
|
+
"minlength", "maxlength", "size", "accept", "autocomplete", "list", "form",
|
|
26
|
+
"formmethod", "formtarget", "formenctype", "wrap", "method", "enctype", "for", "dirname",
|
|
27
|
+
// Text/accessibility
|
|
28
|
+
"alt", "label", "summary", "abbr",
|
|
29
|
+
// Dimensions and layout
|
|
30
|
+
"width", "height", "cols", "rows", "span", "rowspan", "colspan",
|
|
31
|
+
"low", "high", "optimum",
|
|
32
|
+
// Link/resource hints
|
|
33
|
+
"target", "rel", "hreflang", "download", "as", "media", "charset",
|
|
34
|
+
"crossorigin", "integrity", "loading", "decoding", "fetchpriority",
|
|
35
|
+
"referrerpolicy", "blocking", "imagesizes", "sizes",
|
|
36
|
+
// Media
|
|
37
|
+
"preload", "kind", "srclang",
|
|
38
|
+
// Meta
|
|
39
|
+
"content", "http-equiv",
|
|
40
|
+
// iframe/embed
|
|
41
|
+
"sandbox", "allow", "srcdoc", "credentialless",
|
|
42
|
+
// Table
|
|
43
|
+
"headers", "scope",
|
|
44
|
+
// Datetime
|
|
45
|
+
"datetime",
|
|
46
|
+
// Object/embed legacy
|
|
47
|
+
"coords", "shape",
|
|
48
|
+
];
|
|
3
49
|
|
|
4
50
|
pub struct ValueStamper<'a> {
|
|
5
51
|
state: &'a Value,
|
|
6
52
|
binding_regex: Regex,
|
|
7
|
-
|
|
53
|
+
_runtime: Runtime, // Must be kept alive for context to work
|
|
54
|
+
context: Context,
|
|
55
|
+
components_as_is: bool,
|
|
8
56
|
}
|
|
9
57
|
|
|
10
58
|
impl<'a> ValueStamper<'a> {
|
|
11
|
-
pub fn new(state: &'a Value) -> Self {
|
|
12
|
-
|
|
59
|
+
pub fn new(state: &'a Value, components_as_is: bool) -> Result<Self, String> {
|
|
60
|
+
let runtime = Runtime::new().map_err(|e| format!("Failed to create QuickJS runtime: {:?}", e))?;
|
|
61
|
+
let context = Context::full(&runtime).map_err(|e| format!("Failed to create QuickJS context: {:?}", e))?;
|
|
62
|
+
|
|
63
|
+
// Set up state ONCE when creating the stamper (not per expression)
|
|
64
|
+
context.with(|ctx| -> Result<(), String> {
|
|
65
|
+
let state_str = serde_json::to_string(state)
|
|
66
|
+
.map_err(|e| format!("Failed to serialize state: {}", e))?;
|
|
67
|
+
ctx.eval::<(), _>(format!("const $ = {}; Object.assign(globalThis, $)", state_str))
|
|
68
|
+
.map_err(|e| format!("Failed to set up state in QuickJS: {:?}", e))?;
|
|
69
|
+
Ok(())
|
|
70
|
+
})?;
|
|
71
|
+
|
|
72
|
+
Ok(Self {
|
|
13
73
|
state,
|
|
14
74
|
binding_regex: Regex::new(r"@\[((?:[^\[\]]|\[[^\]]*\])+)\]").unwrap(),
|
|
15
|
-
|
|
16
|
-
|
|
75
|
+
_runtime: runtime,
|
|
76
|
+
context,
|
|
77
|
+
components_as_is,
|
|
78
|
+
})
|
|
17
79
|
}
|
|
18
80
|
|
|
19
81
|
pub fn stamp_html(&self, html: String) -> Result<String, String> {
|
|
20
82
|
// First, render iterations (expands templates into multiple instances)
|
|
21
83
|
let html = self.render_iterations(html)?;
|
|
22
84
|
|
|
23
|
-
// Then
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
85
|
+
// Then, render conditionals (resolve if/else branches)
|
|
86
|
+
let html = self.render_conditionals(html)?;
|
|
87
|
+
|
|
88
|
+
// Find all NON_REACTIVE_ELEMENT regions to skip
|
|
89
|
+
let mut skip_regions: Vec<(usize, usize)> = Vec::new();
|
|
90
|
+
|
|
91
|
+
for element_type in NON_REACTIVE_ELEMENTS {
|
|
92
|
+
let regex = Regex::new(&format!(r"(?s)<{}[^>]*>.*?</{}>", element_type, element_type)).unwrap();
|
|
93
|
+
for mat in regex.find_iter(&html) {
|
|
94
|
+
skip_regions.push((mat.start(), mat.end()));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// When components_as_is is true, skip component elements (runtime will handle them)
|
|
99
|
+
// Match both <component> and <div class="component"> elements
|
|
100
|
+
if self.components_as_is {
|
|
101
|
+
// Match <component ...>...</component>
|
|
102
|
+
let component_regex = Regex::new(r"(?s)<component[^>]*>.*?</component>").unwrap();
|
|
103
|
+
for mat in component_regex.find_iter(&html) {
|
|
104
|
+
skip_regions.push((mat.start(), mat.end()));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Match <div class="component" ...>...</div>
|
|
108
|
+
let div_component_regex = Regex::new(r#"(?s)<div\s+class="component"[^>]*>.*?</div>"#).unwrap();
|
|
109
|
+
for mat in div_component_regex.find_iter(&html) {
|
|
110
|
+
skip_regions.push((mat.start(), mat.end()));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Sort and merge overlapping regions
|
|
115
|
+
skip_regions.sort_by_key(|r| r.0);
|
|
116
|
+
|
|
117
|
+
// Stamp bindings only in regions that are NOT in skip_regions
|
|
118
|
+
let mut result = String::new();
|
|
119
|
+
let mut last_pos = 0;
|
|
120
|
+
|
|
121
|
+
for binding_match in self.binding_regex.find_iter(&html) {
|
|
122
|
+
let match_start = binding_match.start();
|
|
123
|
+
let match_end = binding_match.end();
|
|
124
|
+
|
|
125
|
+
// Check if this binding is inside a skip region
|
|
126
|
+
let should_skip = skip_regions.iter().any(|(start, end)| {
|
|
127
|
+
match_start >= *start && match_end <= *end
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Add text before this match
|
|
131
|
+
result.push_str(&html[last_pos..match_start]);
|
|
132
|
+
|
|
133
|
+
if should_skip {
|
|
134
|
+
// Keep the binding marker as-is
|
|
135
|
+
result.push_str(binding_match.as_str());
|
|
136
|
+
} else {
|
|
137
|
+
// Stamp the binding
|
|
138
|
+
let caps = self.binding_regex.captures(binding_match.as_str()).unwrap();
|
|
139
|
+
let expr = &caps[1];
|
|
140
|
+
let stamped = self.eval_expression(expr)
|
|
141
|
+
.unwrap_or_else(|| binding_match.as_str().to_string());
|
|
142
|
+
result.push_str(&stamped);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
last_pos = match_end;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Add remaining text
|
|
149
|
+
result.push_str(&html[last_pos..]);
|
|
150
|
+
|
|
151
|
+
// Finally, clean up boolean-like attributes with falsy values
|
|
152
|
+
// When components_as_is is true, skip cleanup inside component elements
|
|
153
|
+
Ok(self.cleanup_boolean_attributes(result, &skip_regions))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/// Evaluate a JavaScript expression with the current state
|
|
157
|
+
fn eval_expression(&self, expr: &str) -> Option<String> {
|
|
158
|
+
self.eval_with_state(expr, self.state)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/// Evaluate a JavaScript expression with custom state (for nested iterations)
|
|
162
|
+
fn eval_with_state(&self, expr: &str, _state: &Value) -> Option<String> {
|
|
163
|
+
// State is already set up in the context during new(), just evaluate the expression
|
|
164
|
+
self.context.with(|ctx| -> Option<String> {
|
|
165
|
+
// Evaluate expression and convert to string
|
|
166
|
+
let result: rquickjs::Value = ctx.eval(expr).ok()?;
|
|
167
|
+
|
|
168
|
+
// Convert result to string
|
|
169
|
+
if result.is_string() {
|
|
170
|
+
result.as_string().and_then(|s| s.to_string().ok())
|
|
171
|
+
} else if result.is_number() {
|
|
172
|
+
result.as_number().map(|n| n.to_string())
|
|
173
|
+
} else if result.is_bool() {
|
|
174
|
+
result.as_bool().map(|b| b.to_string())
|
|
175
|
+
} else if result.is_null() {
|
|
176
|
+
Some(String::new())
|
|
177
|
+
} else if result.is_undefined() {
|
|
178
|
+
None
|
|
179
|
+
} else {
|
|
180
|
+
// For objects/arrays, convert to JSON
|
|
181
|
+
ctx.json_stringify(result).ok()
|
|
182
|
+
.and_then(|v| v.and_then(|s| s.to_string().ok()))
|
|
183
|
+
}
|
|
184
|
+
})
|
|
29
185
|
}
|
|
30
186
|
|
|
31
187
|
fn render_iterations(&self, html: String) -> Result<String, String> {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
188
|
+
// Use depth counting to properly handle nested iterations
|
|
189
|
+
self.render_iterations_recursive(html)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
fn render_iterations_recursive(&self, html: String) -> Result<String, String> {
|
|
193
|
+
let each_start = Regex::new(r"<!--\s*each\s+(\w+(?:\.\w+)*)\s+as\s+(\w+)(?:\s*,\s*(\w+))?\s*-->").unwrap();
|
|
194
|
+
|
|
195
|
+
let mut result = html.clone();
|
|
196
|
+
let mut search_pos = 0;
|
|
197
|
+
|
|
198
|
+
while let Some(start_match) = each_start.find_at(&result, search_pos) {
|
|
199
|
+
// Extract positions before doing anything else
|
|
200
|
+
let start_pos = start_match.start();
|
|
201
|
+
let template_start = start_match.end();
|
|
202
|
+
|
|
203
|
+
let captures = each_start.captures(&result[start_pos..]).unwrap();
|
|
204
|
+
let array_path = captures.get(1).unwrap().as_str().to_string();
|
|
205
|
+
let item_alias = captures.get(2).unwrap().as_str().to_string();
|
|
206
|
+
let index_alias = captures.get(3).map(|m| m.as_str().to_string()).unwrap_or_else(|| "index".to_string());
|
|
207
|
+
let has_index = captures.get(3).is_some();
|
|
208
|
+
|
|
209
|
+
// Find matching <!-- /each --> using depth counting
|
|
210
|
+
if let Some((end_pos, end_after)) = self.find_matching_end(&result, template_start) {
|
|
211
|
+
let template = result[template_start..end_pos].to_string();
|
|
212
|
+
|
|
213
|
+
// Get array from state using QuickJS
|
|
214
|
+
let array = match self.eval_array_path(&array_path) {
|
|
215
|
+
Some(arr) => arr,
|
|
216
|
+
None => {
|
|
217
|
+
// Array not found, skip this iteration
|
|
218
|
+
search_pos = end_after;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Render each item
|
|
224
|
+
let mut rendered_items = Vec::new();
|
|
225
|
+
for (idx, item) in array.iter().enumerate() {
|
|
226
|
+
// Create temporary merged state for nested iterations
|
|
227
|
+
// This allows nested iterations to reference the parent item (e.g., category.items)
|
|
228
|
+
let mut merged_state = self.state.clone();
|
|
229
|
+
if let Some(obj) = merged_state.as_object_mut() {
|
|
230
|
+
obj.insert(item_alias.clone(), item.clone());
|
|
231
|
+
obj.insert(index_alias.clone(), Value::Number(idx.into()));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Create temp stamper with merged state for this iteration
|
|
235
|
+
let temp_stamper = ValueStamper::new(&merged_state, false)
|
|
236
|
+
.map_err(|e| format!("Failed to create stamper for iteration: {}", e))?;
|
|
237
|
+
|
|
238
|
+
// Render nested iterations first
|
|
239
|
+
let mut item_html = temp_stamper.render_iterations_recursive(template.clone())?;
|
|
240
|
+
|
|
241
|
+
// Then stamp all bindings in this item's context
|
|
242
|
+
item_html = temp_stamper.binding_regex.replace_all(&item_html, |caps: &Captures| {
|
|
243
|
+
let expr = &caps[1];
|
|
244
|
+
temp_stamper.eval_expression(expr)
|
|
245
|
+
.unwrap_or_else(|| caps[0].to_string())
|
|
246
|
+
}).to_string();
|
|
247
|
+
|
|
248
|
+
rendered_items.push(item_html);
|
|
44
249
|
}
|
|
45
|
-
};
|
|
46
250
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
251
|
+
// Reconstruct with comments
|
|
252
|
+
let index_part = if has_index {
|
|
253
|
+
format!(", {}", index_alias)
|
|
254
|
+
} else {
|
|
255
|
+
String::new()
|
|
256
|
+
};
|
|
51
257
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
&format!("@[{}]", index_alias),
|
|
59
|
-
&idx.to_string()
|
|
258
|
+
let replacement = format!(
|
|
259
|
+
"<!-- each {} as {}{} -->{}<!-- /each -->",
|
|
260
|
+
array_path,
|
|
261
|
+
item_alias,
|
|
262
|
+
index_part,
|
|
263
|
+
rendered_items.join("")
|
|
60
264
|
);
|
|
61
265
|
|
|
62
|
-
|
|
63
|
-
|
|
266
|
+
// Replace the entire iteration block
|
|
267
|
+
result.replace_range(start_pos..end_after, &replacement);
|
|
64
268
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
format!(", {}", index_alias)
|
|
269
|
+
// Continue searching after the replacement
|
|
270
|
+
search_pos = start_pos + replacement.len();
|
|
68
271
|
} else {
|
|
69
|
-
|
|
70
|
-
}
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
71
275
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
276
|
+
Ok(result)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/// Evaluate an array path and return the array
|
|
280
|
+
fn eval_array_path(&self, path: &str) -> Option<Vec<Value>> {
|
|
281
|
+
let runtime = Runtime::new().ok()?;
|
|
282
|
+
let context = Context::full(&runtime).ok()?;
|
|
283
|
+
|
|
284
|
+
context.with(|ctx| -> Option<Vec<Value>> {
|
|
285
|
+
// Set state properties in global scope
|
|
286
|
+
let state_str = serde_json::to_string(self.state).ok()?;
|
|
287
|
+
ctx.eval::<(), _>(format!("const $ = {}; Object.assign(globalThis, $)", state_str)).ok()?;
|
|
80
288
|
|
|
81
|
-
|
|
289
|
+
// Evaluate path to get array
|
|
290
|
+
let result: rquickjs::Value = ctx.eval(path).ok()?;
|
|
291
|
+
|
|
292
|
+
// Convert to JSON and parse back to Vec<Value>
|
|
293
|
+
let json_str = ctx.json_stringify(result).ok()??;
|
|
294
|
+
let json_str = json_str.to_string().ok()?;
|
|
295
|
+
serde_json::from_str(&json_str).ok()
|
|
296
|
+
})
|
|
82
297
|
}
|
|
83
298
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
let
|
|
299
|
+
/// Find matching <!-- /each --> using depth counting
|
|
300
|
+
fn find_matching_end(&self, html: &str, start_pos: usize) -> Option<(usize, usize)> {
|
|
301
|
+
let each_start = Regex::new(r"<!--\s*each\s+").unwrap();
|
|
302
|
+
let end_comment = Regex::new(r"<!--\s*/each\s*-->").unwrap();
|
|
303
|
+
|
|
304
|
+
let mut depth = 1;
|
|
305
|
+
let mut search_pos = start_pos;
|
|
87
306
|
|
|
88
|
-
|
|
89
|
-
|
|
307
|
+
while depth > 0 {
|
|
308
|
+
let next_start = each_start.find_at(html, search_pos);
|
|
309
|
+
let next_end = end_comment.find_at(html, search_pos);
|
|
310
|
+
|
|
311
|
+
match (next_start, next_end) {
|
|
312
|
+
(Some(start), Some(end)) if start.start() < end.start() => {
|
|
313
|
+
depth += 1;
|
|
314
|
+
search_pos = start.end();
|
|
315
|
+
}
|
|
316
|
+
(_, Some(end)) => {
|
|
317
|
+
depth -= 1;
|
|
318
|
+
if depth == 0 {
|
|
319
|
+
return Some((end.start(), end.end()));
|
|
320
|
+
}
|
|
321
|
+
search_pos = end.end();
|
|
322
|
+
}
|
|
323
|
+
_ => return None,
|
|
324
|
+
}
|
|
90
325
|
}
|
|
91
326
|
|
|
92
|
-
|
|
327
|
+
None
|
|
93
328
|
}
|
|
94
329
|
|
|
95
|
-
fn
|
|
96
|
-
|
|
97
|
-
let
|
|
98
|
-
|
|
330
|
+
fn render_conditionals(&self, html: String) -> Result<String, String> {
|
|
331
|
+
let if_start = Regex::new(r"<!--\s*if\s+(.+?)\s*-->").unwrap();
|
|
332
|
+
let else_comment = Regex::new(r"<!--\s*else\s*-->").unwrap();
|
|
333
|
+
|
|
334
|
+
let mut result = html.clone();
|
|
335
|
+
let mut search_pos = 0;
|
|
336
|
+
|
|
337
|
+
while let Some(start_match) = if_start.find_at(&result, search_pos) {
|
|
338
|
+
let start_pos = start_match.start();
|
|
339
|
+
let template_start = start_match.end();
|
|
340
|
+
|
|
341
|
+
// Extract condition expression
|
|
342
|
+
let captures = if_start.captures(&result[start_pos..]).unwrap();
|
|
343
|
+
let condition = captures.get(1).unwrap().as_str();
|
|
344
|
+
|
|
345
|
+
// Try to evaluate condition - if it fails (e.g., references iteration variables),
|
|
346
|
+
// skip this conditional and let runtime handle it
|
|
347
|
+
let condition_result = match self.try_eval_condition(condition) {
|
|
348
|
+
Some(result) => result,
|
|
349
|
+
None => {
|
|
350
|
+
// Can't evaluate (likely references iteration variables)
|
|
351
|
+
// Skip and let runtime handle it
|
|
352
|
+
search_pos = template_start;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
// Find matching <!-- /if -->
|
|
358
|
+
if let Some((end_pos, end_after)) = self.find_matching_conditional_end(&result, template_start) {
|
|
359
|
+
// Find <!-- else --> at the same depth level (not nested inside inner conditionals)
|
|
360
|
+
let else_pos = self.find_else_at_same_level(&result, template_start, end_pos);
|
|
99
361
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
let array_name = &part[..bracket_pos];
|
|
104
|
-
let index_str = &part[bracket_pos+1..part.len()-1];
|
|
105
|
-
let index: usize = index_str.parse().ok()?;
|
|
362
|
+
// Keep ALL markers (if/else/endif) regardless of which branch is taken
|
|
363
|
+
// This allows restoration to understand the structure
|
|
364
|
+
let if_comment = &result[start_pos..template_start];
|
|
106
365
|
|
|
107
|
-
|
|
366
|
+
let replacement = if let Some(else_start) = else_pos {
|
|
367
|
+
let else_match = else_comment.find_at(&result, else_start).unwrap();
|
|
368
|
+
let else_marker = &result[else_match.start()..else_match.end()];
|
|
369
|
+
|
|
370
|
+
if condition_result {
|
|
371
|
+
// True branch: stamp if content, keep else empty
|
|
372
|
+
let if_content = result[template_start..else_start].trim().to_string();
|
|
373
|
+
format!("{}{}{}<!-- /if -->", if_comment, if_content, else_marker)
|
|
374
|
+
} else {
|
|
375
|
+
// False branch: keep if empty, stamp else content
|
|
376
|
+
let else_end = else_match.end();
|
|
377
|
+
let else_content = result[else_end..end_pos].trim().to_string();
|
|
378
|
+
format!("{}{}{}<!-- /if -->", if_comment, else_marker, else_content)
|
|
379
|
+
}
|
|
380
|
+
} else {
|
|
381
|
+
// No else branch - only stamp if content was true, otherwise empty
|
|
382
|
+
let content = if condition_result {
|
|
383
|
+
result[template_start..end_pos].trim().to_string()
|
|
384
|
+
} else {
|
|
385
|
+
String::new()
|
|
386
|
+
};
|
|
387
|
+
format!("{}{}<!-- /if -->", if_comment, content)
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// Replace entire conditional block
|
|
391
|
+
result.replace_range(start_pos..end_after, &replacement);
|
|
392
|
+
|
|
393
|
+
// Continue searching after replacement
|
|
394
|
+
search_pos = start_pos + replacement.len();
|
|
108
395
|
} else {
|
|
109
|
-
|
|
396
|
+
break;
|
|
110
397
|
}
|
|
111
398
|
}
|
|
112
399
|
|
|
113
|
-
|
|
400
|
+
Ok(result)
|
|
114
401
|
}
|
|
115
402
|
|
|
116
|
-
fn
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
403
|
+
fn find_matching_conditional_end(&self, html: &str, start_pos: usize) -> Option<(usize, usize)> {
|
|
404
|
+
let if_start = Regex::new(r"<!--\s*if\s+.+?\s*-->").unwrap();
|
|
405
|
+
let if_end = Regex::new(r"<!--\s*/if\s*-->").unwrap();
|
|
406
|
+
|
|
407
|
+
let mut depth = 1;
|
|
408
|
+
let mut search_pos = start_pos;
|
|
409
|
+
|
|
410
|
+
while depth > 0 {
|
|
411
|
+
let next_start = if_start.find_at(html, search_pos);
|
|
412
|
+
let next_end = if_end.find_at(html, search_pos);
|
|
413
|
+
|
|
414
|
+
match (next_start, next_end) {
|
|
415
|
+
(Some(start), Some(end)) if start.start() < end.start() => {
|
|
416
|
+
depth += 1;
|
|
417
|
+
search_pos = start.end();
|
|
418
|
+
}
|
|
419
|
+
(_, Some(end)) => {
|
|
420
|
+
depth -= 1;
|
|
421
|
+
if depth == 0 {
|
|
422
|
+
return Some((end.start(), end.end()));
|
|
423
|
+
}
|
|
424
|
+
search_pos = end.end();
|
|
425
|
+
}
|
|
426
|
+
_ => return None,
|
|
427
|
+
}
|
|
123
428
|
}
|
|
429
|
+
|
|
430
|
+
None
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
fn find_else_at_same_level(&self, html: &str, start_pos: usize, end_pos: usize) -> Option<usize> {
|
|
434
|
+
let if_start = Regex::new(r"<!--\s*if\s+.+?\s*-->").unwrap();
|
|
435
|
+
let if_end = Regex::new(r"<!--\s*/if\s*-->").unwrap();
|
|
436
|
+
let else_comment = Regex::new(r"<!--\s*else\s*-->").unwrap();
|
|
437
|
+
|
|
438
|
+
let mut depth = 0;
|
|
439
|
+
let mut search_pos = start_pos;
|
|
440
|
+
|
|
441
|
+
while search_pos < end_pos {
|
|
442
|
+
// Find the next comment (could be if, else, or /if)
|
|
443
|
+
let next_if = if_start.find_at(html, search_pos);
|
|
444
|
+
let next_else = else_comment.find_at(html, search_pos);
|
|
445
|
+
let next_end = if_end.find_at(html, search_pos);
|
|
446
|
+
|
|
447
|
+
// Determine which comment comes first
|
|
448
|
+
let mut next_pos = end_pos;
|
|
449
|
+
let mut next_type = None; // 0=if, 1=else, 2=end
|
|
450
|
+
|
|
451
|
+
if let Some(m) = next_if {
|
|
452
|
+
if m.start() < next_pos {
|
|
453
|
+
next_pos = m.start();
|
|
454
|
+
next_type = Some(0);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if let Some(m) = next_else {
|
|
458
|
+
if m.start() < next_pos {
|
|
459
|
+
next_pos = m.start();
|
|
460
|
+
next_type = Some(1);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if let Some(m) = next_end {
|
|
464
|
+
if m.start() < next_pos {
|
|
465
|
+
next_pos = m.start();
|
|
466
|
+
next_type = Some(2);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
match next_type {
|
|
471
|
+
Some(0) => {
|
|
472
|
+
// Found nested if - increase depth
|
|
473
|
+
depth += 1;
|
|
474
|
+
search_pos = next_if.unwrap().end();
|
|
475
|
+
}
|
|
476
|
+
Some(1) => {
|
|
477
|
+
// Found else
|
|
478
|
+
if depth == 0 {
|
|
479
|
+
// This is the else at our level!
|
|
480
|
+
return Some(next_pos);
|
|
481
|
+
}
|
|
482
|
+
search_pos = next_else.unwrap().end();
|
|
483
|
+
}
|
|
484
|
+
Some(2) => {
|
|
485
|
+
// Found end - decrease depth
|
|
486
|
+
depth -= 1;
|
|
487
|
+
search_pos = next_end.unwrap().end();
|
|
488
|
+
}
|
|
489
|
+
None => {
|
|
490
|
+
// No more comments found
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
_ => break,
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
None
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
fn try_eval_condition(&self, condition: &str) -> Option<bool> {
|
|
501
|
+
self.context.with(|ctx| -> Option<bool> {
|
|
502
|
+
// Try to evaluate condition
|
|
503
|
+
// If it fails (e.g., references undefined iteration variables), return None
|
|
504
|
+
let result: rquickjs::Value = ctx.eval(condition).ok()?;
|
|
505
|
+
result.as_bool()
|
|
506
|
+
})
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/// Remove boolean-like attributes with falsy values, normalize truthy ones
|
|
510
|
+
/// Boolean-like attributes (not in VALUE_ATTRS) should be:
|
|
511
|
+
/// - Removed entirely when falsy
|
|
512
|
+
/// - Present with empty value when truthy (HTML5 boolean attribute syntax)
|
|
513
|
+
fn cleanup_boolean_attributes(&self, html: String, skip_regions: &[(usize, usize)]) -> String {
|
|
514
|
+
// Create a set for faster lookup
|
|
515
|
+
let value_attrs: HashSet<&str> = VALUE_ATTRS.iter().copied().collect();
|
|
516
|
+
|
|
517
|
+
// Match attributes with their values: attr="value"
|
|
518
|
+
let attr_regex = Regex::new(r#"\s+([\w-]+)="([^"]*)""#).unwrap();
|
|
519
|
+
|
|
520
|
+
attr_regex.replace_all(&html, |caps: &Captures| {
|
|
521
|
+
let match_start = caps.get(0).unwrap().start();
|
|
522
|
+
let match_end = caps.get(0).unwrap().end();
|
|
523
|
+
|
|
524
|
+
// Check if this attribute is inside a skip region
|
|
525
|
+
let in_skip_region = skip_regions.iter().any(|(start, end)| {
|
|
526
|
+
match_start >= *start && match_end <= *end
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// If in skip region, keep attribute as-is
|
|
530
|
+
if in_skip_region {
|
|
531
|
+
return caps[0].to_string();
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
let attr_name = caps[1].to_lowercase();
|
|
535
|
+
let attr_value = &caps[2];
|
|
536
|
+
|
|
537
|
+
// Check if this is a value attribute (should keep its value)
|
|
538
|
+
// Includes: VALUE_ATTRS list + data-* + aria-* + on* (event handlers)
|
|
539
|
+
let is_value_attr = value_attrs.contains(attr_name.as_str())
|
|
540
|
+
|| attr_name.starts_with("data-")
|
|
541
|
+
|| attr_name.starts_with("aria-")
|
|
542
|
+
|| attr_name.starts_with("on");
|
|
543
|
+
|
|
544
|
+
if is_value_attr {
|
|
545
|
+
// Keep value attributes as-is
|
|
546
|
+
return caps[0].to_string();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Check if value is explicitly falsy
|
|
550
|
+
let is_falsy = matches!(
|
|
551
|
+
attr_value,
|
|
552
|
+
"false" | "0" | "null" | "undefined"
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
if is_falsy {
|
|
556
|
+
// Remove the entire attribute (return empty string)
|
|
557
|
+
String::new()
|
|
558
|
+
} else if attr_value.is_empty() {
|
|
559
|
+
// Convert boolean attributes with empty values to short form: `demo=""` -> `demo`
|
|
560
|
+
format!(" {}", &caps[1])
|
|
561
|
+
} else {
|
|
562
|
+
// Keep the attribute as-is
|
|
563
|
+
caps[0].to_string()
|
|
564
|
+
}
|
|
565
|
+
}).to_string()
|
|
124
566
|
}
|
|
125
567
|
}
|
|
126
568
|
|
|
@@ -132,7 +574,7 @@ mod tests {
|
|
|
132
574
|
#[test]
|
|
133
575
|
fn stamp_simple_binding() {
|
|
134
576
|
let state = json!({ "name": "World" });
|
|
135
|
-
let stamper = ValueStamper::new(&state);
|
|
577
|
+
let stamper = ValueStamper::new(&state, false).unwrap();
|
|
136
578
|
let html = String::from("<div>Hello @[name]</div>");
|
|
137
579
|
let result = stamper.stamp_html(html).unwrap();
|
|
138
580
|
assert_eq!(result, "<div>Hello World</div>");
|
|
@@ -141,7 +583,7 @@ mod tests {
|
|
|
141
583
|
#[test]
|
|
142
584
|
fn stamp_multiple_bindings() {
|
|
143
585
|
let state = json!({ "firstName": "John", "lastName": "Doe" });
|
|
144
|
-
let stamper = ValueStamper::new(&state);
|
|
586
|
+
let stamper = ValueStamper::new(&state, false).unwrap();
|
|
145
587
|
let html = String::from("<div>@[firstName] @[lastName]</div>");
|
|
146
588
|
let result = stamper.stamp_html(html).unwrap();
|
|
147
589
|
assert_eq!(result, "<div>John Doe</div>");
|
|
@@ -150,16 +592,25 @@ mod tests {
|
|
|
150
592
|
#[test]
|
|
151
593
|
fn stamp_nested_binding() {
|
|
152
594
|
let state = json!({ "user": { "name": "John" } });
|
|
153
|
-
let stamper = ValueStamper::new(&state);
|
|
595
|
+
let stamper = ValueStamper::new(&state, false).unwrap();
|
|
154
596
|
let html = String::from("<div>Hello @[user.name]</div>");
|
|
155
597
|
let result = stamper.stamp_html(html).unwrap();
|
|
156
598
|
assert_eq!(result, "<div>Hello John</div>");
|
|
157
599
|
}
|
|
158
600
|
|
|
601
|
+
#[test]
|
|
602
|
+
fn stamp_array_length() {
|
|
603
|
+
let state = json!({ "items": ["first", "second", "third"] });
|
|
604
|
+
let stamper = ValueStamper::new(&state, false).unwrap();
|
|
605
|
+
let html = String::from("<div>@[items.length]</div>");
|
|
606
|
+
let result = stamper.stamp_html(html).unwrap();
|
|
607
|
+
assert_eq!(result, "<div>3</div>");
|
|
608
|
+
}
|
|
609
|
+
|
|
159
610
|
#[test]
|
|
160
611
|
fn stamp_array_binding() {
|
|
161
612
|
let state = json!({ "items": ["first", "second", "third"] });
|
|
162
|
-
let stamper = ValueStamper::new(&state);
|
|
613
|
+
let stamper = ValueStamper::new(&state, false).unwrap();
|
|
163
614
|
let html = String::from("<div>@[items[0]]</div>");
|
|
164
615
|
let result = stamper.stamp_html(html).unwrap();
|
|
165
616
|
assert_eq!(result, "<div>first</div>");
|
|
@@ -168,7 +619,7 @@ mod tests {
|
|
|
168
619
|
#[test]
|
|
169
620
|
fn stamp_number_binding() {
|
|
170
621
|
let state = json!({ "count": 42 });
|
|
171
|
-
let stamper = ValueStamper::new(&state);
|
|
622
|
+
let stamper = ValueStamper::new(&state, false).unwrap();
|
|
172
623
|
let html = String::from("<div>Count: @[count]</div>");
|
|
173
624
|
let result = stamper.stamp_html(html).unwrap();
|
|
174
625
|
assert_eq!(result, "<div>Count: 42</div>");
|
|
@@ -177,7 +628,7 @@ mod tests {
|
|
|
177
628
|
#[test]
|
|
178
629
|
fn stamp_boolean_binding() {
|
|
179
630
|
let state = json!({ "isActive": true });
|
|
180
|
-
let stamper = ValueStamper::new(&state);
|
|
631
|
+
let stamper = ValueStamper::new(&state, false).unwrap();
|
|
181
632
|
let html = String::from("<div>Active: @[isActive]</div>");
|
|
182
633
|
let result = stamper.stamp_html(html).unwrap();
|
|
183
634
|
assert_eq!(result, "<div>Active: true</div>");
|
|
@@ -186,7 +637,7 @@ mod tests {
|
|
|
186
637
|
#[test]
|
|
187
638
|
fn stamp_missing_binding() {
|
|
188
639
|
let state = json!({ "name": "World" });
|
|
189
|
-
let stamper = ValueStamper::new(&state);
|
|
640
|
+
let stamper = ValueStamper::new(&state, false).unwrap();
|
|
190
641
|
let html = String::from("<div>Hello @[missing]</div>");
|
|
191
642
|
let result = stamper.stamp_html(html).unwrap();
|
|
192
643
|
// Should keep the binding marker if value doesn't exist
|
|
@@ -196,7 +647,7 @@ mod tests {
|
|
|
196
647
|
#[test]
|
|
197
648
|
fn stamp_attribute_binding() {
|
|
198
649
|
let state = json!({ "firstName": "John" });
|
|
199
|
-
let stamper = ValueStamper::new(&state);
|
|
650
|
+
let stamper = ValueStamper::new(&state, false).unwrap();
|
|
200
651
|
let html = String::from(r#"<input value="@[firstName]">"#);
|
|
201
652
|
let result = stamper.stamp_html(html).unwrap();
|
|
202
653
|
assert_eq!(result, r#"<input value="John">"#);
|
|
@@ -205,7 +656,7 @@ mod tests {
|
|
|
205
656
|
#[test]
|
|
206
657
|
fn render_simple_iteration() {
|
|
207
658
|
let state = json!({ "items": [1, 2, 3] });
|
|
208
|
-
let stamper = ValueStamper::new(&state);
|
|
659
|
+
let stamper = ValueStamper::new(&state, false).unwrap();
|
|
209
660
|
let html = String::from(r#"<!-- each items as item --><div>@[item]</div><!-- /each -->"#);
|
|
210
661
|
let result = stamper.stamp_html(html).unwrap();
|
|
211
662
|
assert_eq!(result, r#"<!-- each items as item --><div>1</div><div>2</div><div>3</div><!-- /each -->"#);
|
|
@@ -214,9 +665,49 @@ mod tests {
|
|
|
214
665
|
#[test]
|
|
215
666
|
fn render_iteration_with_index() {
|
|
216
667
|
let state = json!({ "items": ["a", "b", "c"] });
|
|
217
|
-
let stamper = ValueStamper::new(&state);
|
|
668
|
+
let stamper = ValueStamper::new(&state, false).unwrap();
|
|
218
669
|
let html = String::from(r#"<!-- each items as item, idx --><span>[@[idx]] @[item]</span><!-- /each -->"#);
|
|
219
670
|
let result = stamper.stamp_html(html).unwrap();
|
|
220
671
|
assert_eq!(result, r#"<!-- each items as item, idx --><span>[0] a</span><span>[1] b</span><span>[2] c</span><!-- /each -->"#);
|
|
221
672
|
}
|
|
673
|
+
|
|
674
|
+
#[test]
|
|
675
|
+
fn cleanup_boolean_attributes_false() {
|
|
676
|
+
let state = json!({ "buttonDisabled": false });
|
|
677
|
+
let stamper = ValueStamper::new(&state, false).unwrap();
|
|
678
|
+
let html = String::from(r#"<button disabled="@[buttonDisabled]">Click</button>"#);
|
|
679
|
+
let result = stamper.stamp_html(html).unwrap();
|
|
680
|
+
// disabled="false" should be removed entirely (falsy boolean-like attribute)
|
|
681
|
+
assert_eq!(result, r#"<button>Click</button>"#);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
#[test]
|
|
685
|
+
fn cleanup_boolean_attributes_true() {
|
|
686
|
+
let state = json!({ "buttonDisabled": true });
|
|
687
|
+
let stamper = ValueStamper::new(&state, false).unwrap();
|
|
688
|
+
let html = String::from(r#"<button disabled="@[buttonDisabled]">Click</button>"#);
|
|
689
|
+
let result = stamper.stamp_html(html).unwrap();
|
|
690
|
+
// disabled="true" should become disabled="" (HTML5 boolean attribute syntax)
|
|
691
|
+
assert_eq!(result, r#"<button disabled="">Click</button>"#);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
#[test]
|
|
695
|
+
fn cleanup_keeps_value_attributes() {
|
|
696
|
+
let state = json!({ "inputValue": "false" });
|
|
697
|
+
let stamper = ValueStamper::new(&state, false).unwrap();
|
|
698
|
+
let html = String::from(r#"<input value="@[inputValue]">"#);
|
|
699
|
+
let result = stamper.stamp_html(html).unwrap();
|
|
700
|
+
// value="false" should be kept as-is (value attribute, not boolean-like)
|
|
701
|
+
assert_eq!(result, r#"<input value="false">"#);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
#[test]
|
|
705
|
+
fn cleanup_multiple_boolean_attributes() {
|
|
706
|
+
let state = json!({ "disabled": false, "readonly": true, "required": false });
|
|
707
|
+
let stamper = ValueStamper::new(&state, false).unwrap();
|
|
708
|
+
let html = String::from(r#"<input disabled="@[disabled]" readonly="@[readonly]" required="@[required]">"#);
|
|
709
|
+
let result = stamper.stamp_html(html).unwrap();
|
|
710
|
+
// disabled and required should be removed, readonly should be present with empty value
|
|
711
|
+
assert_eq!(result, r#"<input readonly="">"#);
|
|
712
|
+
}
|
|
222
713
|
}
|