@ape-egg/vibe 1.6.1 → 1.7.1

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.
@@ -40,9 +40,8 @@ const MIRROR_EXTENSIONS: &[&str] = &[
40
40
 
41
41
  // Files and directories to skip when walking source (supports glob patterns)
42
42
  // Note: Output directory is checked dynamically (not hardcoded here)
43
+ // Note: Dotfiles are handled by starts_with('.') check in should_skip_path()
43
44
  pub const SKIP_FILES: &[&str] = &[
44
- ".git",
45
- ".claude",
46
45
  "target", // Rust build artifacts
47
46
  "tests", // Test files
48
47
  "test-results",
@@ -56,6 +55,11 @@ pub const SKIP_FILES: &[&str] = &[
56
55
 
57
56
  /// Check if a path should be skipped based on SKIP_FILES patterns
58
57
  pub fn should_skip_path(path: &Path, name: &str) -> bool {
58
+ // Check if name starts with dot (dotfiles/directories)
59
+ if name.starts_with('.') {
60
+ return true;
61
+ }
62
+
59
63
  // Check exact name match (for directories and simple filenames)
60
64
  if SKIP_FILES.contains(&name) {
61
65
  return true;
@@ -433,6 +437,11 @@ impl Compiler {
433
437
  }
434
438
  }
435
439
 
440
+ /// Clear all cached component content (forces re-fetch on next compile)
441
+ pub fn clear_component_cache(&mut self) {
442
+ self.component_cache.clear();
443
+ }
444
+
436
445
  pub fn compile(&mut self) -> Result<CompileStats, CompileError> {
437
446
  let mut stats = CompileStats {
438
447
  files_compiled: 0,
@@ -479,17 +488,28 @@ impl Compiler {
479
488
  }
480
489
  }
481
490
 
482
- // Validate HTML syntax if requested (optional, controlled by validate flag)
483
- if self.config.validate {
484
- let validation_start = Instant::now();
485
- if self.verbose {
486
- println!("\nValidating HTML syntax...");
487
- }
488
- self.validate_html_syntax()?;
489
- stats.validation_time_ms = Some(validation_start.elapsed().as_secs_f64() * 1000.0);
491
+ // Validate HTML syntax (always runs)
492
+ let validation_start = Instant::now();
493
+ if self.verbose {
494
+ println!("\nValidating HTML syntax...");
495
+ }
496
+ self.validate_html_syntax()?;
497
+ stats.validation_time_ms = Some(validation_start.elapsed().as_secs_f64() * 1000.0);
498
+ if self.verbose {
499
+ println!(" HTML validation completed successfully");
500
+ }
501
+
502
+ // Clean output directory if not in no-clean mode
503
+ if !self.config.no_clean && self.config.output.exists() {
490
504
  if self.verbose {
491
- println!(" HTML validation completed successfully");
505
+ println!(" Cleaning output directory: {}", self.config.output.display());
492
506
  }
507
+ fs::remove_dir_all(&self.config.output).map_err(|e| {
508
+ CompileError::WriteError {
509
+ path: self.config.output.display().to_string(),
510
+ source: e,
511
+ }
512
+ })?;
493
513
  }
494
514
 
495
515
  // Create output directory
@@ -622,6 +642,13 @@ impl Compiler {
622
642
  }
623
643
 
624
644
  for file_path in files {
645
+ // Validate HTML before compiling (same as full compile path)
646
+ let content = fs::read_to_string(file_path).map_err(|e| CompileError::ReadError {
647
+ path: file_path.display().to_string(),
648
+ source: e,
649
+ })?;
650
+ self.validate_html(&content, file_path)?;
651
+
625
652
  // Calculate relative path
626
653
  let relative_path = file_path
627
654
  .strip_prefix(&self.config.source)
@@ -785,30 +812,19 @@ impl Compiler {
785
812
  source_root: &Path,
786
813
  ) -> Result<(), String> {
787
814
  use crate::compiler::manifest_builder::ManifestBuilder;
788
- use crate::compiler::value_stamper::ValueStamper;
789
815
  use crate::compiler::component_tagger::ComponentTagger;
816
+ use crate::compiler::value_stamper::ValueStamper;
790
817
 
791
818
  // Tag components with deterministic IDs and structure state
792
819
  let tagged = ComponentTagger::tag_components(html, &source_root.to_path_buf())?;
793
820
  let html = &tagged.html; // Use modified HTML with data-vibe-component-id attributes
794
821
  let state = tagged.state;
795
822
 
796
- // Build manifest from ORIGINAL HTML (before stamping, so templates have @[...] markers)
823
+ // Build manifest from pre-stamp HTML (both conditional branches present, @[...] markers intact)
824
+ // This ensures restoration.template has both branches so runtime can switch between them
797
825
  let manifest_builder = ManifestBuilder::new();
798
826
  let manifest = manifest_builder.build_from_html(html, &state, iterations_as_is)?;
799
827
 
800
- // Stamp values (pre-render) AFTER building manifest
801
- // When components_as_is is true, skip stamping inside component elements (runtime will handle them)
802
- let stamper = ValueStamper::new(&state, components_as_is)?;
803
- let mut pre_rendered = stamper.stamp_html(html.to_string())?;
804
-
805
- // Remove FOUC prevention since HTML is pre-rendered
806
- pre_rendered = remove_fouc_prevention(pre_rendered);
807
-
808
- // Write pre-rendered HTML
809
- fs::write(html_path, pre_rendered)
810
- .map_err(|e| format!("Failed to write HTML: {}", e))?;
811
-
812
828
  // Write manifest
813
829
  let manifest_path = output_dir
814
830
  .join("vibe-hyperspeed")
@@ -832,6 +848,20 @@ impl Compiler {
832
848
  fs::write(&manifest_path, manifest_js)
833
849
  .map_err(|e| format!("Failed to write manifest: {}", e))?;
834
850
 
851
+ // Stamp the compiled HTML with initial state values for FOUC prevention:
852
+ // - @[...] bindings replaced with their initial values
853
+ // - Only the active conditional branch is kept (inactive branch stripped)
854
+ // - Iterations are pre-rendered with initial array data
855
+ // The manifest (written above) preserves BOTH branches so the runtime can
856
+ // restore them and switch between branches reactively.
857
+ let stamper = ValueStamper::new(&state, components_as_is)
858
+ .map_err(|e| format!("Failed to create value stamper: {}", e))?;
859
+ let stamped = stamper.stamp_html(html.to_string())
860
+ .map_err(|e| format!("Failed to stamp HTML: {}", e))?;
861
+
862
+ fs::write(html_path, stamped)
863
+ .map_err(|e| format!("Failed to write stamped HTML: {}", e))?;
864
+
835
865
  Ok(())
836
866
  }
837
867
 
@@ -1076,11 +1106,6 @@ impl Compiler {
1076
1106
  source: e,
1077
1107
  })?;
1078
1108
 
1079
- // Validate if requested
1080
- if self.config.validate {
1081
- self.validate_html(&content, path)?;
1082
- }
1083
-
1084
1109
  // Count <component src="..."> instances before processing (only if not components_as_is)
1085
1110
  let (internal_count, external_count, component_srcs) = if !self.config.components_as_is {
1086
1111
  self.extract_components(&content)
@@ -1099,12 +1124,17 @@ impl Compiler {
1099
1124
  );
1100
1125
 
1101
1126
  // Minify if requested
1102
- let output = if self.config.minify {
1127
+ let mut output = if self.config.minify {
1103
1128
  minify_html(&processed)
1104
1129
  } else {
1105
1130
  processed
1106
1131
  };
1107
1132
 
1133
+ // Remove FOUC prevention unless fouc_as_is is enabled
1134
+ if !self.config.fouc_as_is {
1135
+ output = remove_fouc_prevention(output);
1136
+ }
1137
+
1108
1138
  // Write to output
1109
1139
  let output_path = self.get_output_path(path, relative_path)?;
1110
1140
  fs::write(&output_path, output).map_err(|e| CompileError::WriteError {
@@ -1212,8 +1242,8 @@ impl Compiler {
1212
1242
  // Transform custom tags to <component> tags
1213
1243
  let transformed = parser.process_html(
1214
1244
  &content,
1215
- false, // elements_as_is
1216
- &[], // reserved_elements
1245
+ self.config.elements_as_is,
1246
+ &self.config.reserved_elements,
1217
1247
  true, // components_as_is (don't inline, just transform)
1218
1248
  &self.config.components,
1219
1249
  );
@@ -1249,8 +1279,8 @@ impl Compiler {
1249
1279
  // Transform custom tags to <component> tags
1250
1280
  let transformed = parser.process_html(
1251
1281
  &content,
1252
- false, // elements_as_is
1253
- &[], // reserved_elements
1282
+ self.config.elements_as_is,
1283
+ &self.config.reserved_elements,
1254
1284
  true, // components_as_is (don't inline, just transform)
1255
1285
  &self.config.components,
1256
1286
  );
@@ -1463,8 +1493,8 @@ impl Compiler {
1463
1493
  let file_name = entry.file_name();
1464
1494
  let file_name_str = file_name.to_string_lossy();
1465
1495
 
1466
- // Skip hidden files and specific directories/patterns
1467
- if file_name_str.starts_with('.') || should_skip_path(&path, &file_name_str) {
1496
+ // Skip specific directories/patterns (includes dotfiles via SKIP_FILES)
1497
+ if should_skip_path(&path, &file_name_str) {
1468
1498
  continue;
1469
1499
  }
1470
1500
 
@@ -1505,8 +1535,8 @@ impl Compiler {
1505
1535
  }
1506
1536
 
1507
1537
  fn validate_html(&self, content: &str, path: &Path) -> Result<(), CompileError> {
1538
+ // Check 1: Quote balance per line
1508
1539
  let mut line_num = 1;
1509
-
1510
1540
  for line in content.lines() {
1511
1541
  let quote_count = line.matches('"').count();
1512
1542
  if quote_count % 2 != 0 {
@@ -1519,9 +1549,77 @@ impl Compiler {
1519
1549
  line_num += 1;
1520
1550
  }
1521
1551
 
1552
+ // Check 2: Tag balance (unclosed elements break slot extraction)
1553
+ self.validate_tag_balance(content, path)
1554
+ }
1555
+
1556
+ fn validate_tag_balance(&self, content: &str, path: &Path) -> Result<(), CompileError> {
1557
+ const VOID_ELEMENTS: &[&str] = &[
1558
+ "area", "base", "br", "col", "embed", "hr", "img", "input",
1559
+ "link", "meta", "param", "source", "track", "wbr",
1560
+ ];
1561
+ // These are implicitly closed by the parser — don't require explicit close tags
1562
+ const IMPLICIT_CLOSE: &[&str] = &[
1563
+ "html", "head", "body", "p", "li", "dt", "dd", "option",
1564
+ "optgroup", "tr", "td", "th", "thead", "tbody", "tfoot", "colgroup",
1565
+ ];
1566
+
1567
+ // Strip comments, raw content blocks, and @[...] bindings before tag scanning
1568
+ let stripped = Self::strip_for_tag_validation(content);
1569
+
1570
+ // Match any tag: opening, self-closing, or closing
1571
+ let tag_re = Regex::new(r"</?([a-zA-Z][a-zA-Z0-9-]*)(?:\s[^>]*)?>").unwrap();
1572
+
1573
+ // Count opens vs closes per tag name; track line of first open for error reporting
1574
+ let mut counts: HashMap<String, (i32, usize)> = HashMap::new();
1575
+ for cap in tag_re.captures_iter(&stripped) {
1576
+ let full = cap.get(0).unwrap().as_str();
1577
+ let name = cap[1].to_lowercase();
1578
+ if VOID_ELEMENTS.contains(&name.as_str()) || IMPLICIT_CLOSE.contains(&name.as_str()) {
1579
+ continue;
1580
+ }
1581
+ if full.starts_with("</") {
1582
+ counts.entry(name).and_modify(|(c, _)| *c -= 1);
1583
+ } else if full.ends_with("/>") {
1584
+ // self-closing — no balance change
1585
+ } else {
1586
+ let offset = cap.get(0).unwrap().start();
1587
+ let line = stripped[..offset].matches('\n').count() + 1;
1588
+ let entry = counts.entry(name).or_insert((0, line));
1589
+ entry.0 += 1;
1590
+ }
1591
+ }
1592
+
1593
+ // Report the first unclosed tag (sorted by line for deterministic output)
1594
+ let mut unclosed: Vec<(String, usize)> = counts.into_iter()
1595
+ .filter(|(_, (count, _))| *count > 0)
1596
+ .map(|(name, (_, line))| (name, line))
1597
+ .collect();
1598
+ unclosed.sort_by_key(|(_, line)| *line);
1599
+
1600
+ if let Some((name, line)) = unclosed.first() {
1601
+ return Err(CompileError::ValidationError {
1602
+ file: path.display().to_string(),
1603
+ line: *line,
1604
+ message: format!("Unclosed tag <{}>", name),
1605
+ });
1606
+ }
1607
+
1522
1608
  Ok(())
1523
1609
  }
1524
1610
 
1611
+ fn strip_for_tag_validation(content: &str) -> String {
1612
+ // Strip HTML comments <!-- ... --> (includes Vibe syntax: each, if, else, /if, /each)
1613
+ let comment_re = Regex::new(r"(?s)<!--.*?-->").unwrap();
1614
+ let s = comment_re.replace_all(content, "");
1615
+ // Strip @[...] reactive bindings (can contain < and > characters)
1616
+ let binding_re = Regex::new(r"@\[[^\]]*\]").unwrap();
1617
+ let s = binding_re.replace_all(&s, "");
1618
+ // Strip content inside <script>, <style>, <pre> to avoid parsing embedded code as HTML
1619
+ let raw_re = Regex::new(r"(?si)(<(?:script|style|pre)(?:\s[^>]*)?>).*?(</(?:script|style|pre)>)").unwrap();
1620
+ raw_re.replace_all(&s, "$1$2").into_owned()
1621
+ }
1622
+
1525
1623
  /// Copy an entire directory recursively
1526
1624
  fn copy_directory(
1527
1625
  &mut self,
@@ -20,10 +20,23 @@ pub struct TaggedResult {
20
20
  impl ComponentTagger {
21
21
  /// Find component wrappers, add deterministic IDs, and structure state
22
22
  pub fn tag_components(html: &str, base_path: &PathBuf) -> Result<TaggedResult, String> {
23
- // Parse HTML
23
+ // Extract ALL <template> content to protect it from HTML parser
24
+ // HTML parsers can strip content from <template> tags during serialization
25
+ let template_regex = regex::Regex::new(r"(?s)<template[^>]*>.*?</template>").unwrap();
26
+ let mut template_placeholders: Vec<String> = Vec::new();
27
+ let mut html_with_placeholders = html.to_string();
28
+
29
+ for (i, mat) in template_regex.find_iter(html).enumerate() {
30
+ let content = mat.as_str();
31
+ let placeholder = format!("<!--VIBE_TEMPLATE_PLACEHOLDER_{}-->", i);
32
+ template_placeholders.push(content.to_string());
33
+ html_with_placeholders = html_with_placeholders.replace(content, &placeholder);
34
+ }
35
+
36
+ // Parse HTML (with placeholders instead of actual vibe-dehydrate content)
24
37
  let dom = parse_document(RcDom::default(), Default::default())
25
38
  .from_utf8()
26
- .read_from(&mut html.as_bytes())
39
+ .read_from(&mut html_with_placeholders.as_bytes())
27
40
  .map_err(|e| format!("Failed to parse HTML: {:?}", e))?;
28
41
 
29
42
  // Find all component wrappers and extract their state
@@ -39,9 +52,15 @@ impl ComponentTagger {
39
52
  SerializeOpts::default()
40
53
  ).map_err(|e| format!("Failed to serialize HTML: {:?}", e))?;
41
54
 
42
- let modified_html = String::from_utf8(modified_html_bytes)
55
+ let mut modified_html = String::from_utf8(modified_html_bytes)
43
56
  .map_err(|e| format!("Failed to convert HTML to UTF-8: {}", e))?;
44
57
 
58
+ // Restore template content from placeholders
59
+ for (i, content) in template_placeholders.iter().enumerate() {
60
+ let placeholder = format!("<!--VIBE_TEMPLATE_PLACEHOLDER_{}-->", i);
61
+ modified_html = modified_html.replace(&placeholder, content);
62
+ }
63
+
45
64
  // Extract all state from HTML
46
65
  let all_state = StateExtractor::extract_from_html(html, base_path)?;
47
66
 
@@ -108,46 +127,55 @@ impl ComponentTagger {
108
127
  if is_component || is_div_component {
109
128
  drop(borrowed_attrs); // Release borrow before checking innerHTML
110
129
 
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();
130
+ // Only extract state from DIRECT <script> children — not the full subtree.
131
+ // Using the full subtree caused the Layout wrapper (a <component> created by
132
+ // inline_component_elements that contains all nested component scripts) to be
133
+ // treated as a stateful component with the merged state of all its descendants.
134
+ // That made rewrite_this_to_component_id rewrite every @[this.xxx] in the page
135
+ // to @[_c0.xxx] before child components could claim their own bindings.
136
+ let mut direct_scripts_html = String::new();
114
137
  for child in node.children.borrow().iter() {
115
- let _ = serialize(
116
- &mut inner_html_bytes,
117
- &SerializableHandle::from(child.clone()),
118
- SerializeOpts::default()
119
- );
138
+ if let NodeData::Element { name: ref child_name, .. } = child.data {
139
+ if child_name.local.as_ref() == "script" {
140
+ let mut script_bytes = Vec::new();
141
+ let _ = serialize(
142
+ &mut script_bytes,
143
+ &SerializableHandle::from(child.clone()),
144
+ SerializeOpts::default()
145
+ );
146
+ if let Ok(script_str) = String::from_utf8(script_bytes) {
147
+ direct_scripts_html.push_str(&script_str);
148
+ }
149
+ }
150
+ }
120
151
  }
121
152
 
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
- }
153
+ if let Ok(Value::Object(comp_state)) = StateExtractor::extract_from_html(
154
+ &direct_scripts_html,
155
+ &PathBuf::from(".")
156
+ ) {
157
+ // Only tag and register components that have state
158
+ if !comp_state.is_empty() {
159
+ let component_id = format!("_c{}", *counter.borrow());
160
+ *counter.borrow_mut() += 1;
161
+
162
+ // Add data-vibe-component-id attribute
163
+ let mut attrs_mut = attrs.borrow_mut();
164
+ attrs_mut.push(markup5ever::Attribute {
165
+ name: QualName::new(
166
+ None,
167
+ Namespace::from(""),
168
+ LocalName::from("data-vibe-component-id"),
169
+ ),
170
+ value: component_id.clone().into(),
171
+ });
172
+ drop(attrs_mut);
173
+
174
+ // Rewrite this.property to componentId.property in the node's children
175
+ // This allows ValueStamper to properly evaluate component-scoped expressions
176
+ Self::rewrite_this_to_component_id(node, &component_id);
177
+
178
+ component_states.borrow_mut().push((component_id, Value::Object(comp_state)));
151
179
  }
152
180
  }
153
181
  }
@@ -79,8 +79,21 @@ impl<'a> ValueStamper<'a> {
79
79
  }
80
80
 
81
81
  pub fn stamp_html(&self, html: String) -> Result<String, String> {
82
+ // Extract vibe-dehydrate content to protect it from processing
83
+ // This prevents @[...] markers and iterations/conditionals from being processed
84
+ let dehydrate_regex = Regex::new(r"(?s)<template[^>]*\svibe-dehydrate[^>]*>.*?</template>").unwrap();
85
+ let mut dehydrate_placeholders: Vec<String> = Vec::new();
86
+ let mut html_with_placeholders = html.clone();
87
+
88
+ for (i, mat) in dehydrate_regex.find_iter(&html).enumerate() {
89
+ let content = mat.as_str();
90
+ let placeholder = format!("<!--VIBE_DEHYDRATE_PLACEHOLDER_{}-->", i);
91
+ dehydrate_placeholders.push(content.to_string());
92
+ html_with_placeholders = html_with_placeholders.replace(content, &placeholder);
93
+ }
94
+
82
95
  // First, render iterations (expands templates into multiple instances)
83
- let html = self.render_iterations(html)?;
96
+ let html = self.render_iterations(html_with_placeholders)?;
84
97
 
85
98
  // Then, render conditionals (resolve if/else branches)
86
99
  let html = self.render_conditionals(html)?;
@@ -95,6 +108,13 @@ impl<'a> ValueStamper<'a> {
95
108
  }
96
109
  }
97
110
 
111
+ // Skip elements with vibe-dehydrate attribute (content should not be processed)
112
+ // This is a secondary protection in case placeholders aren't used
113
+ let dehydrate_regex = Regex::new(r"(?s)<template[^>]*\svibe-dehydrate[^>]*>.*?</template>").unwrap();
114
+ for mat in dehydrate_regex.find_iter(&html) {
115
+ skip_regions.push((mat.start(), mat.end()));
116
+ }
117
+
98
118
  // When components_as_is is true, skip component elements (runtime will handle them)
99
119
  // Match both <component> and <div class="component"> elements
100
120
  if self.components_as_is {
@@ -148,6 +168,15 @@ impl<'a> ValueStamper<'a> {
148
168
  // Add remaining text
149
169
  result.push_str(&html[last_pos..]);
150
170
 
171
+ // Clean up attributes that still contain unresolved markers
172
+ result = self.cleanup_unresolved_attributes(result);
173
+
174
+ // Restore vibe-dehydrate content from placeholders
175
+ for (i, content) in dehydrate_placeholders.iter().enumerate() {
176
+ let placeholder = format!("<!--VIBE_DEHYDRATE_PLACEHOLDER_{}-->", i);
177
+ result = result.replace(&placeholder, content);
178
+ }
179
+
151
180
  // Finally, clean up boolean-like attributes with falsy values
152
181
  // When components_as_is is true, skip cleanup inside component elements
153
182
  Ok(self.cleanup_boolean_attributes(result, &skip_regions))
@@ -238,13 +267,19 @@ impl<'a> ValueStamper<'a> {
238
267
  // Render nested iterations first
239
268
  let mut item_html = temp_stamper.render_iterations_recursive(template.clone())?;
240
269
 
241
- // Then stamp all bindings in this item's context
270
+ // Then render conditionals with this item's context
271
+ item_html = temp_stamper.render_conditionals(item_html)?;
272
+
273
+ // Finally stamp all bindings in this item's context
242
274
  item_html = temp_stamper.binding_regex.replace_all(&item_html, |caps: &Captures| {
243
275
  let expr = &caps[1];
244
276
  temp_stamper.eval_expression(expr)
245
277
  .unwrap_or_else(|| caps[0].to_string())
246
278
  }).to_string();
247
279
 
280
+ // Clean up attributes with unresolved markers (e.g., data-tutorial="@[item.id]" when item.id is undefined)
281
+ item_html = temp_stamper.cleanup_unresolved_attributes(item_html);
282
+
248
283
  rendered_items.push(item_html);
249
284
  }
250
285
 
@@ -500,9 +535,31 @@ impl<'a> ValueStamper<'a> {
500
535
  fn try_eval_condition(&self, condition: &str) -> Option<bool> {
501
536
  self.context.with(|ctx| -> Option<bool> {
502
537
  // 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()
538
+ let result: rquickjs::Value = match ctx.eval(condition) {
539
+ Ok(val) => val,
540
+ Err(_) => {
541
+ // Evaluation failed (undefined variable, syntax error, etc.)
542
+ // Default to false as per requirements
543
+ return Some(false);
544
+ }
545
+ };
546
+
547
+ // Use JavaScript truthiness rules instead of requiring boolean type
548
+ // This allows conditions like "item.id" to work correctly
549
+ if result.is_bool() {
550
+ result.as_bool()
551
+ } else if result.is_null() || result.is_undefined() {
552
+ Some(false)
553
+ } else if result.is_number() {
554
+ // 0, NaN are falsy
555
+ result.as_number().map(|n| n != 0.0 && !n.is_nan())
556
+ } else if result.is_string() {
557
+ // Empty string is falsy
558
+ result.as_string().and_then(|s| s.to_string().ok()).map(|s| !s.is_empty())
559
+ } else {
560
+ // Objects, arrays are truthy
561
+ Some(true)
562
+ }
506
563
  })
507
564
  }
508
565
 
@@ -510,6 +567,19 @@ impl<'a> ValueStamper<'a> {
510
567
  /// Boolean-like attributes (not in VALUE_ATTRS) should be:
511
568
  /// - Removed entirely when falsy
512
569
  /// - Present with empty value when truthy (HTML5 boolean attribute syntax)
570
+ /// Remove attributes that still contain unresolved markers (e.g., data-tutorial="@[item.id]")
571
+ /// These occur when iterating over items where some don't have the referenced property
572
+ fn cleanup_unresolved_attributes(&self, html: String) -> String {
573
+ // Match attributes with marker values: attr="@[...]"
574
+ // Use non-greedy match and look for closing "]" at end of attribute value
575
+ let attr_with_marker = Regex::new(r#"\s+([\w-]+)="@\[.+?\]""#).unwrap();
576
+
577
+ attr_with_marker.replace_all(&html, |_caps: &Captures| {
578
+ // Remove the entire attribute when it has an unresolved marker
579
+ String::new()
580
+ }).to_string()
581
+ }
582
+
513
583
  fn cleanup_boolean_attributes(&self, html: String, skip_regions: &[(usize, usize)]) -> String {
514
584
  // Create a set for faster lookup
515
585
  let value_attrs: HashSet<&str> = VALUE_ATTRS.iter().copied().collect();