@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.
@@ -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
- iteration_regex: Regex,
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
- Self {
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
- iteration_regex: Regex::new(r"(?s)<!--\s*each\s+(\w+(?:\.\w+)*)\s+as\s+(\w+)(?:\s*,\s*(\w+))?\s*-->(.*?)<!--\s*/each\s*-->").unwrap(),
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 stamp all bindings (including those in rendered iterations)
24
- Ok(self.binding_regex.replace_all(&html, |caps: &Captures| {
25
- let path = &caps[1];
26
- self.resolve_state_path(path)
27
- .unwrap_or_else(|| caps[0].to_string()) // Keep marker if can't resolve
28
- }).to_string())
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
- let result = self.iteration_regex.replace_all(&html, |caps: &Captures| {
33
- let array_path = &caps[1];
34
- let item_alias = &caps[2];
35
- let index_alias = caps.get(3).map(|m| m.as_str()).unwrap_or("index");
36
- let template = &caps[4];
37
-
38
- // Get array from state
39
- let array = match self.resolve_array_path(array_path) {
40
- Some(arr) => arr,
41
- None => {
42
- // Array not found, keep iteration block as-is
43
- return caps[0].to_string();
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
- // Render each item
48
- let mut rendered_items = Vec::new();
49
- for (idx, item) in array.iter().enumerate() {
50
- let mut item_html = template.to_string();
251
+ // Reconstruct with comments
252
+ let index_part = if has_index {
253
+ format!(", {}", index_alias)
254
+ } else {
255
+ String::new()
256
+ };
51
257
 
52
- // Replace @[item] and @[index] in template
53
- item_html = item_html.replace(
54
- &format!("@[{}]", item_alias),
55
- &self.value_to_string(item)
56
- );
57
- item_html = item_html.replace(
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
- rendered_items.push(item_html);
63
- }
266
+ // Replace the entire iteration block
267
+ result.replace_range(start_pos..end_after, &replacement);
64
268
 
65
- // Reconstruct with comments (preserve index alias if it was in original)
66
- let index_part = if caps.get(3).is_some() {
67
- format!(", {}", index_alias)
269
+ // Continue searching after the replacement
270
+ search_pos = start_pos + replacement.len();
68
271
  } else {
69
- String::new()
70
- };
272
+ break;
273
+ }
274
+ }
71
275
 
72
- format!(
73
- "<!-- each {} as {}{} -->{}<!-- /each -->",
74
- array_path,
75
- item_alias,
76
- index_part,
77
- rendered_items.join("")
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
- Ok(result.to_string())
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
- fn resolve_array_path(&self, path: &str) -> Option<&Vec<Value>> {
85
- let parts: Vec<&str> = path.split('.').collect();
86
- let mut current = self.state;
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
- for part in parts {
89
- current = current.get(part)?;
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
- current.as_array()
327
+ None
93
328
  }
94
329
 
95
- fn resolve_state_path(&self, path: &str) -> Option<String> {
96
- // Navigate state by dot path: "user.name" -> state["user"]["name"]
97
- let parts: Vec<&str> = path.split('.').collect();
98
- let mut current = self.state;
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
- for part in parts {
101
- // Handle array access: items[0]
102
- if let Some(bracket_pos) = part.find('[') {
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
- current = current.get(array_name)?.get(index)?;
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
- current = current.get(part)?;
396
+ break;
110
397
  }
111
398
  }
112
399
 
113
- Some(self.value_to_string(current))
400
+ Ok(result)
114
401
  }
115
402
 
116
- fn value_to_string(&self, val: &Value) -> String {
117
- match val {
118
- Value::String(s) => s.clone(),
119
- Value::Number(n) => n.to_string(),
120
- Value::Bool(b) => b.to_string(),
121
- Value::Null => String::new(),
122
- Value::Array(_) | Value::Object(_) => val.to_string(),
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
  }