@ape-egg/vibe 1.6.0 → 1.7.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.
@@ -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
 
@@ -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();
@@ -328,7 +328,7 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
328
328
 
329
329
  // Keep compiler and parser alive to reuse component cache across incremental compilations
330
330
  let mut watch_compiler = Compiler::new(config.clone(), false);
331
- let parser = {
331
+ let mut parser = {
332
332
  use crate::parser::HtmlParser;
333
333
  let mut p = HtmlParser::new(config.components_path());
334
334
  if let Err(e) = p.load_elements() {
@@ -396,10 +396,52 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
396
396
  let dependent_pages = graph.get_all_dependent_pages(&path_canonical);
397
397
  if !dependent_pages.is_empty() {
398
398
  println!("{} {} changed", "[watch]".cyan(), relative_path.display());
399
+
400
+ // Invalidate component cache so stale content isn't used
401
+ watch_compiler.clear_component_cache();
402
+
403
+ // Reload the changed component in the parser's element cache
404
+ // (used for custom element syntax like <Layout>)
405
+ if path.exists() {
406
+ let _ = parser.reload_element(path);
407
+ }
408
+
399
409
  pages_to_recompile.extend(dependent_pages);
400
410
  }
401
411
  } else if let Some(ext) = path.extension() {
402
412
  if ext == "html" {
413
+ // Check if file still exists (handle deletions)
414
+ if !path.exists() {
415
+ // File was deleted - clean up dependency graph
416
+ println!("{} {} deleted", "[watch]".yellow(), relative_path.display());
417
+
418
+ if let Some(old_deps) = graph.page_to_components.remove(path) {
419
+ for dep in old_deps {
420
+ if let Some(pages) = graph.component_to_pages.get_mut(&dep) {
421
+ pages.remove(path);
422
+ }
423
+ }
424
+ }
425
+
426
+ // Delete corresponding compiled output
427
+ let output_path = canonical_output.join(relative_path);
428
+ if output_path.exists() {
429
+ if let Err(e) = std::fs::remove_file(&output_path) {
430
+ eprintln!("{}: Failed to delete {}: {}", "Warning".yellow(), output_path.display(), e);
431
+ }
432
+ }
433
+
434
+ // Delete corresponding manifest
435
+ let manifest_path = canonical_output.join("vibe-hyperspeed").join(format!("{}.manifest.js", relative_path.display()));
436
+ if manifest_path.exists() {
437
+ if let Err(e) = std::fs::remove_file(&manifest_path) {
438
+ eprintln!("{}: Failed to delete manifest {}: {}", "Warning".yellow(), manifest_path.display(), e);
439
+ }
440
+ }
441
+
442
+ continue;
443
+ }
444
+
403
445
  // Page changed - recompile just this page
404
446
  println!("{} {} changed", "[watch]".cyan(), relative_path.display());
405
447
  pages_to_recompile.insert(path.clone());
@@ -431,7 +473,10 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
431
473
  }
432
474
 
433
475
  // Separate HTML files (from dependency graph) and CSS/JS assets (from changed_paths)
434
- let html_files: Vec<PathBuf> = pages_to_recompile.into_iter().collect();
476
+ // Filter out deleted files
477
+ let html_files: Vec<PathBuf> = pages_to_recompile.into_iter()
478
+ .filter(|p| p.exists())
479
+ .collect();
435
480
 
436
481
  let mut asset_files: Vec<PathBuf> = Vec::new();
437
482
  for path in &changed_paths {
@@ -446,6 +491,24 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
446
491
  continue;
447
492
  }
448
493
 
494
+ // Handle deleted asset files
495
+ if !path.exists() {
496
+ if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
497
+ if ext == "css" || ext == "js" {
498
+ let relative_path = path.strip_prefix(&config.source).unwrap_or(path);
499
+ let output_path = canonical_output.join(relative_path);
500
+ if output_path.exists() {
501
+ if let Err(e) = std::fs::remove_file(&output_path) {
502
+ eprintln!("{}: Failed to delete {}: {}", "Warning".yellow(), output_path.display(), e);
503
+ } else {
504
+ println!("{} {} deleted", "[watch]".yellow(), relative_path.display());
505
+ }
506
+ }
507
+ }
508
+ }
509
+ continue;
510
+ }
511
+
449
512
  if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
450
513
  if ext == "css" || ext == "js" {
451
514
  asset_files.push(path.clone());
@@ -482,6 +545,10 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
482
545
 
483
546
  // Compile HTML files
484
547
  if !html_files.is_empty() {
548
+ // Always clear component cache before recompiling so components
549
+ // are read fresh from disk (prevents stale cache from previous triggers)
550
+ watch_compiler.clear_component_cache();
551
+
485
552
  match watch_compiler.compile_specific_html_files(&html_files, &parser) {
486
553
  Ok(stats) => {
487
554
  total_stats.files_compiled = stats.files_compiled;
@@ -44,6 +44,10 @@ pub struct VibeCompilerConfig {
44
44
  pub runtime_as_is: bool,
45
45
  #[serde(default)]
46
46
  pub iterations_as_is: bool,
47
+ #[serde(default)]
48
+ pub no_clean: bool,
49
+ #[serde(default)]
50
+ pub fouc_as_is: bool,
47
51
  }
48
52
 
49
53
  fn default_source() -> String { "./".to_string() }
@@ -99,6 +103,8 @@ impl Default for VibeCompilerConfig {
99
103
  components_as_is: false,
100
104
  runtime_as_is: false,
101
105
  iterations_as_is: false,
106
+ no_clean: false,
107
+ fouc_as_is: false,
102
108
  }
103
109
  }
104
110
  }
@@ -122,6 +128,8 @@ pub struct Config {
122
128
  pub components_as_is: bool,
123
129
  pub runtime_as_is: bool,
124
130
  pub iterations_as_is: bool,
131
+ pub no_clean: bool,
132
+ pub fouc_as_is: bool,
125
133
  pub working_dir: PathBuf,
126
134
  }
127
135
 
@@ -168,6 +176,8 @@ impl Config {
168
176
  components_as_is: config.components_as_is,
169
177
  runtime_as_is: config.runtime_as_is,
170
178
  iterations_as_is: config.iterations_as_is,
179
+ no_clean: config.no_clean,
180
+ fouc_as_is: config.fouc_as_is,
171
181
  working_dir,
172
182
  }
173
183
  }
@@ -26,7 +26,7 @@ struct ConfigOverrides {
26
26
  #[derive(ClapParser, Debug)]
27
27
  #[command(name = "vibe-compile")]
28
28
  #[command(author = "Kim Korte")]
29
- #[command(version = "0.1.0")]
29
+ #[command(version = "1.6.1")]
30
30
  #[command(about = "Compiles Vibe source files into optimized output")]
31
31
  struct Args {
32
32
  /// Working directory (defaults to current directory)
@@ -84,6 +84,14 @@ struct Args {
84
84
  /// Skip iteration optimization - use runtime DOM cloning instead of compiled batch functions
85
85
  #[arg(long, name = "iterations-as-is")]
86
86
  iterations_as_is: bool,
87
+
88
+ /// Skip cleaning output directory before compilation
89
+ #[arg(long, name = "no-clean", hide = true)]
90
+ no_clean: bool,
91
+
92
+ /// Keep FOUC prevention class/attribute in compiled output
93
+ #[arg(long, name = "fouc-as-is")]
94
+ fouc_as_is: bool,
87
95
  }
88
96
 
89
97
  fn main() {
@@ -161,9 +169,15 @@ fn main() {
161
169
  overrides.iterations_as_is = !original_iterations_as_is;
162
170
  config.iterations_as_is = true;
163
171
  }
172
+ if args.no_clean {
173
+ config.no_clean = true;
174
+ }
175
+ if args.fouc_as_is {
176
+ config.fouc_as_is = true;
177
+ }
164
178
 
165
179
  if args.verbose {
166
- println!("{}", "Vibe Compiler".cyan().bold());
180
+ println!("{}", format!("Vibe Compiler v{}", env!("CARGO_PKG_VERSION")).cyan().bold());
167
181
  println!();
168
182
  println!(" {}: {}", "Working dir".cyan(), config.working_dir.display());
169
183
  println!(" {}: {}", "Source".cyan(), config.source.display());
@@ -191,22 +205,35 @@ fn main() {
191
205
  format!("{} (config: {})", value.to_string().green(), value.to_string().yellow())
192
206
  }
193
207
 
194
- // Alphabetically ordered with padding (longest key is "node-modules-as-is" = 18 chars)
195
- println!(" {}: {}", format!("{:<18}", "assets").cyan(), format_value_no_flag(&config._assets));
196
- println!(" {}: {}", format!("{:<18}", "components").cyan(), format_value_no_flag(&config.components));
197
- println!(" {}: {}", format!("{:<18}", "components-as-is").cyan(), format_bool_with_flag(config.components_as_is, overrides.components_as_is, original_components_as_is));
198
- println!(" {}: {}", format!("{:<18}", "elements-as-is").cyan(), format_bool_with_flag(config.elements_as_is, overrides.elements_as_is, original_elements_as_is));
199
- println!(" {}: {}", format!("{:<18}", "exclude-tags").cyan(), format_value_no_flag(format!("{:?}", config.reserved_elements)));
200
- println!(" {}: {}", format!("{:<18}", "minify").cyan(), format_bool_with_flag(config.minify, overrides.minify, original_minify));
201
- println!(" {}: {}", format!("{:<18}", "node-modules-as-is").cyan(), format_bool_with_flag(config.node_modules_as_is, overrides.node_modules_as_is, original_node_modules_as_is));
202
- println!(" {}: {}", format!("{:<18}", "output").cyan(), format_value_no_flag(&config._output_str));
203
- println!(" {}: {}", format!("{:<18}", "pages").cyan(), format_value_no_flag(&config.pages));
204
- println!(" {}: {}", format!("{:<18}", "root").cyan(), format_value_no_flag(config._root.as_ref().map(|s| s.as_str()).unwrap_or("null")));
205
- println!(" {}: {}", format!("{:<18}", "runtime-as-is").cyan(), format_bool_with_flag(config.runtime_as_is, overrides.runtime_as_is, original_runtime_as_is));
206
- println!(" {}: {}", format!("{:<18}", "source").cyan(), format_value_no_flag(&config._source_str));
207
- println!(" {}: {}", format!("{:<18}", "source-maps").cyan(), format_bool_with_flag(config.source_maps, overrides.source_maps, original_source_maps));
208
- println!(" {}: {}", format!("{:<18}", "validate").cyan(), format_bool_with_flag(config.validate, overrides.validate, original_validate));
208
+ fn format_reserved_elements(elements: &[String]) -> String {
209
+ if elements.len() <= 2 {
210
+ format!("{:?}", elements)
211
+ } else {
212
+ let remaining = elements.len() - 2;
213
+ format!("[\"component\", \"div\", ... + {} more]", remaining)
214
+ }
215
+ }
216
+
217
+ // Alphabetically ordered with padding (longest key is "reservedElements" = 16 chars)
218
+ println!(" {}: {}", format!("{:<16}", "assets").cyan(), format_value_no_flag(&config._assets));
219
+ println!(" {}: {}", format!("{:<16}", "components").cyan(), format_value_no_flag(&config.components));
220
+ println!(" {}: {}", format!("{:<16}", "componentsAsIs").cyan(), format_bool_with_flag(config.components_as_is, overrides.components_as_is, original_components_as_is));
221
+ println!(" {}: {}", format!("{:<16}", "elementsAsIs").cyan(), format_bool_with_flag(config.elements_as_is, overrides.elements_as_is, original_elements_as_is));
222
+ println!(" {}: {}", format!("{:<16}", "iterationsAsIs").cyan(), format_bool_with_flag(config.iterations_as_is, overrides.iterations_as_is, original_iterations_as_is));
223
+ println!(" {}: {}", format!("{:<16}", "minify").cyan(), format_bool_with_flag(config.minify, overrides.minify, original_minify));
224
+ println!(" {}: {}", format!("{:<16}", "nodeModulesAsIs").cyan(), format_bool_with_flag(config.node_modules_as_is, overrides.node_modules_as_is, original_node_modules_as_is));
225
+ println!(" {}: {}", format!("{:<16}", "output").cyan(), format_value_no_flag(&config._output_str));
226
+ println!(" {}: {}", format!("{:<16}", "pages").cyan(), format_value_no_flag(&config.pages));
227
+ println!(" {}: {}", format!("{:<16}", "reservedElements").cyan(), format_value_no_flag(format_reserved_elements(&config.reserved_elements)));
228
+ println!(" {}: {}", format!("{:<16}", "root").cyan(), format_value_no_flag(config._root.as_ref().map(|s| s.as_str()).unwrap_or("null")));
229
+ println!(" {}: {}", format!("{:<16}", "runtimeAsIs").cyan(), format_bool_with_flag(config.runtime_as_is, overrides.runtime_as_is, original_runtime_as_is));
230
+ println!(" {}: {}", format!("{:<16}", "source").cyan(), format_value_no_flag(&config._source_str));
231
+ println!(" {}: {}", format!("{:<16}", "sourceMaps").cyan(), format_bool_with_flag(config.source_maps, overrides.source_maps, original_source_maps));
232
+ println!(" {}: {}", format!("{:<16}", "validate").cyan(), format_bool_with_flag(config.validate, overrides.validate, original_validate));
209
233
  println!();
234
+ } else {
235
+ // Show version in non-verbose mode
236
+ println!("{}", format!("Vibe Compiler v{}", env!("CARGO_PKG_VERSION")).cyan());
210
237
  }
211
238
 
212
239
  // Handle watch mode
@@ -243,7 +270,7 @@ fn main() {
243
270
  }
244
271
 
245
272
  // Show success headline
246
- println!("\n{}", "Compilation successful! ✅".green().bold());
273
+ println!("\n{}", format!("Compilation successful! (v{}) ✅", env!("CARGO_PKG_VERSION")).green().bold());
247
274
  println!();
248
275
 
249
276
  // Show individual phase timings (validation first, then components, HTML, manifests, copied)
@@ -71,6 +71,21 @@ impl HtmlParser {
71
71
  Ok(())
72
72
  }
73
73
 
74
+ /// Reload a single element from disk (when the file changes in watch mode)
75
+ pub fn reload_element(&mut self, path: &Path) -> Result<(), ParseError> {
76
+ if path.extension().map_or(false, |ext| ext == "html") {
77
+ if let Some(tag_name) = path.file_stem().and_then(|s| s.to_str()) {
78
+ let content = fs::read_to_string(path).map_err(|e| ParseError::ReadError {
79
+ path: path.display().to_string(),
80
+ source: e,
81
+ })?;
82
+ let element = Element::new(tag_name.to_string(), path.to_path_buf(), content);
83
+ self.cache.insert(tag_name.to_string(), element);
84
+ }
85
+ }
86
+ Ok(())
87
+ }
88
+
74
89
  /// Get an element by tag name
75
90
  pub fn _get_element(&self, tag_name: &str) -> Option<&Element> {
76
91
  self.cache.get(tag_name)
@@ -115,8 +130,20 @@ impl HtmlParser {
115
130
 
116
131
  // Step 2: Handle explicit <component src="..."> elements
117
132
  // If components_as_is is false, recursively inline all <component> elements
133
+ // Keep running until no more components are found (handles nested components in slots)
118
134
  if !components_as_is {
119
- result = self.inline_component_elements(&result, external_cache);
135
+ let mut iterations = 0;
136
+ let max_iterations = 50; // Prevent infinite loops
137
+ loop {
138
+ let before = result.clone();
139
+ result = self.inline_component_elements(&result, external_cache);
140
+ iterations += 1;
141
+
142
+ // Stop if no changes or max iterations reached
143
+ if result == before || iterations >= max_iterations {
144
+ break;
145
+ }
146
+ }
120
147
 
121
148
  // NOTE: Don't run inline_custom_elements again here - it causes infinite recursion
122
149
  // Custom elements inside components are already processed when the component was cached
@@ -366,7 +393,7 @@ impl HtmlParser {
366
393
  replacement = replacement.replace("<slot/>", slot_replacement);
367
394
  replacement = replacement.replace("<slot />", slot_replacement);
368
395
 
369
- // Keep the <component> wrapper
396
+ // Keep the <component> wrapper (without src attribute)
370
397
  let wrapper = format!("<component>{}</component>", replacement);
371
398
  result.replace_range(*start..*end, &wrapper);
372
399
  }
@@ -440,16 +467,41 @@ fn transform_custom_tags_to_divs(content: &str, reserved_elements: &[String]) ->
440
467
  }
441
468
  }
442
469
 
470
+ // Sort by descending length so more specific tags (e.g. "accordion-content")
471
+ // are processed before shorter prefixes (e.g. "accordion"), preventing
472
+ // partial tag-name matches like <accordion([^>]*)> matching <accordion-content>
473
+ custom_tags.sort_by(|a, b| b.len().cmp(&a.len()));
474
+
475
+ // Pre-compile class attribute regex for merging existing class values
476
+ let class_attr_re = regex::Regex::new(r#"\bclass="([^"]*)""#).unwrap();
477
+
443
478
  // Transform each custom tag
444
- for tag in custom_tags {
445
- // Opening tag: <custom-tag attrs> -> <div class="custom-tag" attrs>
446
- let open_re = regex::Regex::new(&format!(r"<{}([^>]*)>", regex::escape(&tag))).unwrap();
447
- result = open_re
448
- .replace_all(&result, format!("<div class=\"{}\"$1>", tag).as_str())
449
- .to_string();
479
+ for tag in &custom_tags {
480
+ // Opening tag: require whitespace or end-of-tag after the tag name so that
481
+ // <accordion> does not accidentally match <accordion-content>
482
+ let open_re = regex::Regex::new(
483
+ &format!(r"<{}([\s][^>]*|)>", regex::escape(tag))
484
+ ).unwrap();
485
+
486
+ result = open_re.replace_all(&result, |caps: &regex::Captures| -> String {
487
+ let attrs = caps.get(1).map(|m| m.as_str()).unwrap_or("");
488
+
489
+ // If the element already has class="...", merge tag name with existing value
490
+ if let Some(class_cap) = class_attr_re.captures(attrs) {
491
+ let existing = class_cap.get(1).unwrap().as_str();
492
+ let merged = format!("{} {}", tag, existing);
493
+ let new_attrs = class_attr_re.replace(
494
+ attrs,
495
+ format!(r#"class="{}""#, merged.trim()).as_str(),
496
+ );
497
+ format!("<div{}>", new_attrs)
498
+ } else {
499
+ format!("<div class=\"{}\"{}>", tag, attrs)
500
+ }
501
+ }).to_string();
450
502
 
451
503
  // Closing tag: </custom-tag> -> </div>
452
- let close_re = regex::Regex::new(&format!(r"</{}>", regex::escape(&tag))).unwrap();
504
+ let close_re = regex::Regex::new(&format!(r"</{}>", regex::escape(tag))).unwrap();
453
505
  result = close_re.replace_all(&result, "</div>").to_string();
454
506
  }
455
507
 
package/index.js CHANGED
@@ -1,15 +1,41 @@
1
1
  // Universal entry point for Vibe
2
2
  // Usage: import vibe from 'vibe/index.js'; vibe({ initialState }, { debug: true }, 'body');
3
3
 
4
- import { ensureBoot, boot, isBooted } from './boot.js';
4
+ import { boot, isBooted, ensureBoot } from './boot.js';
5
+
6
+ // Shared instance for queueing listeners before boot
7
+ let vibeInstance = null;
8
+
9
+ const createVibeInstance = () => ({
10
+ _pendingListeners: { afterUpdate: [], afterDomMutation: [], ready: [] },
11
+ on(event, callback) {
12
+ // If booted, delegate to window.$
13
+ if (isBooted() && window.$) {
14
+ return window.$.on(event, callback);
15
+ }
16
+
17
+ // Otherwise queue for later
18
+ if (this._pendingListeners[event]) {
19
+ this._pendingListeners[event].push(callback);
20
+ }
21
+ return () => {
22
+ this._pendingListeners[event] = this._pendingListeners[event].filter(cb => cb !== callback);
23
+ };
24
+ }
25
+ });
5
26
 
6
27
  const vibe = (state = {}, config, targetSelector) => {
7
28
  if (isBooted()) {
8
- // Already booted - mutate live state
29
+ // Already booted - merge state into live proxy
9
30
  Object.assign(window.$, state);
10
31
  return window.$;
11
32
  }
12
33
 
34
+ // Create shared instance on first call
35
+ if (!vibeInstance) {
36
+ vibeInstance = createVibeInstance();
37
+ }
38
+
13
39
  // Not booted yet - accumulate in global state registry
14
40
  if (!window.__vibeGlobalState) {
15
41
  window.__vibeGlobalState = {};
@@ -26,15 +52,18 @@ const vibe = (state = {}, config, targetSelector) => {
26
52
  window.__vibeTargetSelector = targetSelector;
27
53
  }
28
54
 
29
- // Explicit boot call (no state passed means "boot now")
55
+ // Explicit boot call (no state passed means "boot now with accumulated state")
30
56
  if (Object.keys(state).length === 0 && Object.keys(window.__vibeGlobalState).length > 0) {
31
57
  return boot();
32
58
  }
33
59
 
34
- // Ensure boot happens in microtask
60
+ // Queue boot in microtask to allow all component scripts to register
35
61
  ensureBoot();
36
62
 
37
- return null;
63
+ return vibeInstance;
38
64
  };
39
65
 
66
+ // Export function to get pending listeners (used by boot.js)
67
+ export const getPendingListeners = () => vibeInstance?._pendingListeners || null;
68
+
40
69
  export default vibe;
package/llms.txt CHANGED
@@ -129,7 +129,7 @@ Conditionals can be nested inside iterations and vice versa.
129
129
  Skip reactive processing for an element and its children:
130
130
 
131
131
  ```html
132
- <code dehydrate>@[this] displays literally, not parsed</code>
132
+ <code vibe-dehydrate>@[this] displays literally, not parsed</code>
133
133
  ```
134
134
 
135
135
  Use cases:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",
@@ -68,13 +68,4 @@ export const cleanup = (rootElement, debug = false) => {
68
68
  debug,
69
69
  );
70
70
  }
71
-
72
- // Dispatch ready event to signal that Vibe has completed all initial processing
73
- if (typeof document !== 'undefined') {
74
- document.dispatchEvent(
75
- new CustomEvent('vibe:ready', {
76
- detail: { rootElement, cleanName, isClass },
77
- }),
78
- );
79
- }
80
71
  };