@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.
@@ -0,0 +1,579 @@
1
+ use std::collections::{HashMap, HashSet};
2
+ use std::path::{Path, PathBuf};
3
+ use std::sync::mpsc::channel;
4
+ use std::time::Duration;
5
+ use colored::Colorize;
6
+ use notify_debouncer_full::{new_debouncer, notify::{RecursiveMode, Watcher}};
7
+ use regex::Regex;
8
+
9
+ use super::{Compiler, compile::should_skip_path};
10
+ use crate::config::Config;
11
+
12
+ /// Tracks which pages and components depend on which components
13
+ pub struct DependencyGraph {
14
+ /// component path -> set of page paths that use it
15
+ component_to_pages: HashMap<PathBuf, HashSet<PathBuf>>,
16
+ /// component path -> set of component paths that use it (for transitive dependencies)
17
+ component_to_components: HashMap<PathBuf, HashSet<PathBuf>>,
18
+ /// page path -> set of component paths it uses
19
+ page_to_components: HashMap<PathBuf, HashSet<PathBuf>>,
20
+ }
21
+
22
+ impl DependencyGraph {
23
+ pub fn new() -> Self {
24
+ Self {
25
+ component_to_pages: HashMap::new(),
26
+ component_to_components: HashMap::new(),
27
+ page_to_components: HashMap::new(),
28
+ }
29
+ }
30
+
31
+ /// Add a dependency: file (page or component) uses component
32
+ pub fn add_dependency(&mut self, file: PathBuf, component: PathBuf, file_is_component: bool) {
33
+ if file_is_component {
34
+ // Component uses another component
35
+ self.component_to_components
36
+ .entry(component.clone())
37
+ .or_insert_with(HashSet::new)
38
+ .insert(file);
39
+ } else {
40
+ // Page uses component
41
+ self.component_to_pages
42
+ .entry(component.clone())
43
+ .or_insert_with(HashSet::new)
44
+ .insert(file.clone());
45
+
46
+ self.page_to_components
47
+ .entry(file)
48
+ .or_insert_with(HashSet::new)
49
+ .insert(component);
50
+ }
51
+ }
52
+
53
+ /// Get all pages that transitively depend on a component (includes component -> component -> page chains)
54
+ pub fn get_all_dependent_pages(&self, component: &Path) -> HashSet<PathBuf> {
55
+ let mut all_pages = HashSet::new();
56
+ let mut visited_components = HashSet::new();
57
+ self.collect_dependent_pages(component, &mut all_pages, &mut visited_components);
58
+ all_pages
59
+ }
60
+
61
+ fn collect_dependent_pages(
62
+ &self,
63
+ component: &Path,
64
+ all_pages: &mut HashSet<PathBuf>,
65
+ visited: &mut HashSet<PathBuf>,
66
+ ) {
67
+ // Avoid infinite loops in circular dependencies
68
+ if !visited.insert(component.to_path_buf()) {
69
+ return;
70
+ }
71
+
72
+ // Add pages that directly use this component
73
+ if let Some(pages) = self.component_to_pages.get(component) {
74
+ all_pages.extend(pages.iter().cloned());
75
+ }
76
+
77
+ // Recursively add pages that use components that use this component
78
+ if let Some(dependent_components) = self.component_to_components.get(component) {
79
+ for dep_component in dependent_components {
80
+ self.collect_dependent_pages(dep_component, all_pages, visited);
81
+ }
82
+ }
83
+ }
84
+ }
85
+
86
+ /// Extract component references from HTML content
87
+ pub fn extract_component_dependencies(html: &str, components_dir: &Path) -> HashSet<PathBuf> {
88
+ let mut deps = HashSet::new();
89
+
90
+ // 1. <component src="/components/card.html"> - works in both compiled and runtime modes
91
+ let component_tag_re = Regex::new(r#"<component\s+[^>]*src=["']([^"']+)["']"#).unwrap();
92
+ for cap in component_tag_re.captures_iter(html) {
93
+ if let Some(src) = cap.get(1) {
94
+ let src_path = src.as_str().trim_start_matches('/');
95
+ deps.insert(PathBuf::from(src_path));
96
+ }
97
+ }
98
+
99
+ // 2. Custom elements: <UserCard> -> /components/user-card.html
100
+ let custom_element_re = Regex::new(r"<([A-Z][a-zA-Z0-9]*)[\s>]").unwrap();
101
+ for cap in custom_element_re.captures_iter(html) {
102
+ if let Some(tag) = cap.get(1) {
103
+ let kebab = to_kebab_case(tag.as_str());
104
+ let component_path = components_dir.join(format!("{}.html", kebab));
105
+ // Store relative to source root
106
+ if let Ok(rel_path) = component_path.strip_prefix("/") {
107
+ deps.insert(rel_path.to_path_buf());
108
+ } else {
109
+ deps.insert(component_path);
110
+ }
111
+ }
112
+ }
113
+
114
+ deps
115
+ }
116
+
117
+ /// Convert PascalCase to kebab-case
118
+ fn to_kebab_case(s: &str) -> String {
119
+ let mut result = String::new();
120
+ for (i, ch) in s.chars().enumerate() {
121
+ if ch.is_uppercase() && i > 0 {
122
+ result.push('-');
123
+ }
124
+ result.push(ch.to_ascii_lowercase());
125
+ }
126
+ result
127
+ }
128
+
129
+ /// Check if a path should be blacklisted based on SKIP_FILES patterns
130
+ /// This checks both the filename and all path components relative to source root
131
+ fn is_path_blacklisted(path: &Path, source_root: &Path) -> bool {
132
+ let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
133
+
134
+ // Check filename against blacklist
135
+ if should_skip_path(path, file_name) {
136
+ return true;
137
+ }
138
+
139
+ // Check if any path component (relative to source) matches blacklist
140
+ if let Ok(relative) = path.strip_prefix(source_root) {
141
+ for component in relative.components() {
142
+ if let Some(component_str) = component.as_os_str().to_str() {
143
+ if should_skip_path(path, component_str) {
144
+ return true;
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ false
151
+ }
152
+
153
+ /// Build dependency graph by scanning all HTML files
154
+ pub fn build_dependency_graph(config: &Config) -> std::result::Result<DependencyGraph, std::io::Error> {
155
+ let mut graph = DependencyGraph::new();
156
+
157
+ // Canonicalize output path for reliable comparison
158
+ let canonical_output = config.output.canonicalize()
159
+ .unwrap_or_else(|_| config.output.clone());
160
+
161
+ // Scan all HTML files in source directory
162
+ scan_directory(&config.source, &config.source, &config.components, &canonical_output, &mut graph)?;
163
+
164
+ Ok(graph)
165
+ }
166
+
167
+ fn scan_directory(
168
+ dir: &Path,
169
+ source_root: &Path,
170
+ components_dir: &str,
171
+ output_dir: &Path,
172
+ graph: &mut DependencyGraph,
173
+ ) -> std::result::Result<(), std::io::Error> {
174
+ if !dir.is_dir() {
175
+ return Ok(());
176
+ }
177
+
178
+ let components_path = source_root.join(components_dir);
179
+
180
+ for entry in std::fs::read_dir(dir)? {
181
+ let entry = entry?;
182
+ let path = entry.path();
183
+ let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
184
+
185
+ if path.is_dir() {
186
+ // Skip output directory (dynamically check, not hardcoded "compiled")
187
+ let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone());
188
+ if canonical_path.starts_with(output_dir) {
189
+ continue;
190
+ }
191
+
192
+ // Skip directories using shared skip logic
193
+ if should_skip_path(&path, file_name) {
194
+ continue;
195
+ }
196
+
197
+ scan_directory(&path, source_root, components_dir, output_dir, graph)?;
198
+ } else if let Some(ext) = path.extension() {
199
+ if ext == "html" {
200
+ let html = std::fs::read_to_string(&path)?;
201
+ let deps = extract_component_dependencies(&html, &components_path);
202
+
203
+ // Check if this file is itself a component
204
+ let file_is_component = path.starts_with(&components_path);
205
+
206
+ for dep in deps {
207
+ let dep_absolute = source_root.join(&dep);
208
+ // Canonicalize to handle case-insensitive filesystems (macOS)
209
+ let dep_canonical = dep_absolute.canonicalize().unwrap_or(dep_absolute);
210
+ graph.add_dependency(path.clone(), dep_canonical, file_is_component);
211
+ }
212
+ }
213
+ }
214
+ }
215
+
216
+ Ok(())
217
+ }
218
+
219
+ /// Start watching for file changes
220
+ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn std::error::Error>> {
221
+ println!("{}", "Building dependency graph...".cyan());
222
+ let mut graph = build_dependency_graph(&config)?;
223
+
224
+ println!("{}", "Running initial compilation...".cyan());
225
+ let mut compiler = Compiler::new(config.clone(), verbose);
226
+
227
+ match compiler.compile() {
228
+ Ok(stats) => {
229
+ // Generate manifests BEFORE showing success (unless runtime-as-is is enabled)
230
+ let mut manifest_time_ms = 0.0;
231
+ let mut manifest_stats_result = None;
232
+ if !config.runtime_as_is {
233
+ match compiler.generate_manifests() {
234
+ Ok(manifest_stats) => {
235
+ manifest_time_ms = manifest_stats.total_time_ms;
236
+ manifest_stats_result = Some(manifest_stats);
237
+ }
238
+ Err(e) => {
239
+ eprintln!("\n{}: Manifest generation failed: {}", "Warning".yellow(), e);
240
+ eprintln!("Compilation succeeded but manifests were not generated.");
241
+ }
242
+ }
243
+ }
244
+
245
+ // Show success headline
246
+ println!("\n{}", "Initial compilation complete!".green().bold());
247
+ println!();
248
+
249
+ // Show individual phase timings (same as main.rs output)
250
+
251
+ // Show validation time if it happened
252
+ if let Some(validation_time) = stats.validation_time_ms {
253
+ println!("* Validated components in {:.0}ms", validation_time);
254
+ }
255
+
256
+ let total_components_unique = stats.internal_components_unique + stats.external_components_unique;
257
+
258
+ // Always show components line
259
+ if stats.components_as_is {
260
+ println!("* Compiled components (0 internal, 0 external) - \"components-as-is\": true");
261
+ } else if total_components_unique > 0 {
262
+ println!("* Compiled components ({} internal, {} external) in {:.0}ms",
263
+ stats.internal_components_unique,
264
+ stats.external_components_unique,
265
+ stats.components_time_ms
266
+ );
267
+ }
268
+
269
+ if stats.files_compiled > 0 {
270
+ println!("* Compiled HTML ({} file{}) in {:.0}ms",
271
+ stats.files_compiled,
272
+ if stats.files_compiled == 1 { "" } else { "s" },
273
+ stats.compile_time_ms
274
+ );
275
+ }
276
+
277
+ // Show manifest stats between HTML and Copied files
278
+ if let Some(manifest_stats) = manifest_stats_result {
279
+ println!("* Generated manifests ({} file{}, {} skipped) in {:.0}ms",
280
+ manifest_stats.pages_processed,
281
+ if manifest_stats.pages_processed == 1 { "" } else { "s" },
282
+ manifest_stats.pages_skipped,
283
+ manifest_stats.total_time_ms
284
+ );
285
+ }
286
+
287
+ if stats.files_copied > 0 {
288
+ println!("* Copied files ({} file{}) in {:.0}ms",
289
+ stats.files_copied,
290
+ if stats.files_copied == 1 { "" } else { "s" },
291
+ stats.copy_time_ms
292
+ );
293
+ }
294
+
295
+ // Show node_modules handling
296
+ if let Some(nm_time) = stats.node_modules_time_ms {
297
+ if stats.node_modules_copied_as_is {
298
+ println!("* copied node_modules in {:.0}ms", nm_time);
299
+ } else if let Some(ref pkg_manager) = stats.package_manager {
300
+ println!("* {} install in {:.0}ms", pkg_manager, nm_time);
301
+ }
302
+ }
303
+
304
+ // Calculate total duration as sum of all individual operations
305
+ let total_duration_ms = stats.validation_time_ms.unwrap_or(0.0)
306
+ + stats.components_time_ms
307
+ + stats.compile_time_ms
308
+ + stats.copy_time_ms
309
+ + stats.node_modules_time_ms.unwrap_or(0.0)
310
+ + manifest_time_ms;
311
+
312
+ println!("\n{} in {:.0}ms", "Compiled".green(), total_duration_ms);
313
+ println!();
314
+ }
315
+ Err(e) => {
316
+ eprintln!("{}: {}", "Error".red(), e);
317
+ eprintln!("Fix the errors and save to retry.");
318
+ println!();
319
+ }
320
+ }
321
+
322
+ println!("{}", "Watching for changes... (Ctrl+C to stop)".cyan());
323
+ println!();
324
+
325
+ // Canonicalize output path for reliable comparison
326
+ let canonical_output = config.output.canonicalize()
327
+ .unwrap_or_else(|_| config.output.clone());
328
+
329
+ // Keep compiler and parser alive to reuse component cache across incremental compilations
330
+ let mut watch_compiler = Compiler::new(config.clone(), false);
331
+ let parser = {
332
+ use crate::parser::HtmlParser;
333
+ let mut p = HtmlParser::new(config.components_path());
334
+ if let Err(e) = p.load_elements() {
335
+ eprintln!("{}: Failed to load parser: {}", "Error".red(), e);
336
+ return Err(Box::new(e));
337
+ }
338
+ p
339
+ };
340
+
341
+ let (tx, rx) = channel();
342
+ let mut debouncer = new_debouncer(Duration::from_millis(100), None, tx)?;
343
+
344
+ debouncer.watcher().watch(&config.source, RecursiveMode::Recursive)?;
345
+
346
+ loop {
347
+ match rx.recv() {
348
+ Ok(result) => {
349
+ match result {
350
+ Ok(events) => {
351
+ // Collect unique paths from events
352
+ let mut changed_paths: HashSet<PathBuf> = HashSet::new();
353
+
354
+ for event in events {
355
+ for path in &event.paths {
356
+ // Skip blacklisted files/directories (check entire path, not just filename)
357
+ if is_path_blacklisted(path, &config.source) {
358
+ continue;
359
+ }
360
+
361
+ if let Some(ext) = path.extension() {
362
+ if ext == "html" || ext == "css" || ext == "js" {
363
+ changed_paths.insert(path.clone());
364
+ }
365
+ }
366
+ }
367
+ }
368
+
369
+ if changed_paths.is_empty() {
370
+ continue;
371
+ }
372
+
373
+ // Determine what needs recompiling
374
+ let mut pages_to_recompile: HashSet<PathBuf> = HashSet::new();
375
+
376
+ for path in &changed_paths {
377
+ // Skip files in output directory (avoid infinite loop)
378
+ let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone());
379
+ if canonical_path.starts_with(&canonical_output) {
380
+ continue;
381
+ }
382
+
383
+ // Skip blacklisted files/directories
384
+ if is_path_blacklisted(path, &config.source) {
385
+ continue;
386
+ }
387
+
388
+ let relative_path = path.strip_prefix(&config.source)
389
+ .unwrap_or(path);
390
+
391
+ // Check if it's a component
392
+ if path.starts_with(&config.source.join(&config.components)) {
393
+ // Component changed - recompile all transitively dependent pages
394
+ // Canonicalize to match how dependencies were stored (handles case sensitivity)
395
+ let path_canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
396
+ let dependent_pages = graph.get_all_dependent_pages(&path_canonical);
397
+ if !dependent_pages.is_empty() {
398
+ println!("{} {} changed", "[watch]".cyan(), relative_path.display());
399
+ pages_to_recompile.extend(dependent_pages);
400
+ }
401
+ } else if let Some(ext) = path.extension() {
402
+ if ext == "html" {
403
+ // Page changed - recompile just this page
404
+ println!("{} {} changed", "[watch]".cyan(), relative_path.display());
405
+ pages_to_recompile.insert(path.clone());
406
+
407
+ // Rebuild dependencies for this page
408
+ if let Ok(html) = std::fs::read_to_string(path) {
409
+ let components_path = config.source.join(&config.components);
410
+ let deps = extract_component_dependencies(&html, &components_path);
411
+
412
+ // Clear old dependencies for this page
413
+ if let Some(old_deps) = graph.page_to_components.get(path) {
414
+ for dep in old_deps {
415
+ if let Some(pages) = graph.component_to_pages.get_mut(dep) {
416
+ pages.remove(path);
417
+ }
418
+ }
419
+ }
420
+
421
+ // Add new dependencies
422
+ graph.page_to_components.insert(path.clone(), HashSet::new());
423
+ for dep in deps {
424
+ let dep_absolute = config.source.join(&dep);
425
+ let dep_canonical = dep_absolute.canonicalize().unwrap_or(dep_absolute);
426
+ graph.add_dependency(path.clone(), dep_canonical, false);
427
+ }
428
+ }
429
+ }
430
+ }
431
+ }
432
+
433
+ // 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();
435
+
436
+ let mut asset_files: Vec<PathBuf> = Vec::new();
437
+ for path in &changed_paths {
438
+ // Skip files in output directory
439
+ let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone());
440
+ if canonical_path.starts_with(&canonical_output) {
441
+ continue;
442
+ }
443
+
444
+ // Skip blacklisted files/directories
445
+ if is_path_blacklisted(path, &config.source) {
446
+ continue;
447
+ }
448
+
449
+ if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
450
+ if ext == "css" || ext == "js" {
451
+ asset_files.push(path.clone());
452
+ }
453
+ }
454
+ }
455
+
456
+ if html_files.is_empty() && asset_files.is_empty() {
457
+ continue;
458
+ }
459
+
460
+ let start = std::time::Instant::now();
461
+
462
+ // Use persistent compiler and parser (cache is preserved)
463
+ let mut total_stats = super::CompileStats {
464
+ files_compiled: 0,
465
+ files_copied: 0,
466
+ internal_components_unique: 0,
467
+ external_components_unique: 0,
468
+ internal_components_total: 0,
469
+ external_components_total: 0,
470
+ compile_time_ms: 0.0,
471
+ copy_time_ms: 0.0,
472
+ components_time_ms: 0.0,
473
+ validation_time_ms: None,
474
+ node_modules_time_ms: None,
475
+ package_manager: None,
476
+ node_modules_copied_as_is: false,
477
+ components_as_is: config.components_as_is,
478
+ };
479
+
480
+ let mut manifest_stats_result = None;
481
+ let mut had_errors = false;
482
+
483
+ // Compile HTML files
484
+ if !html_files.is_empty() {
485
+ match watch_compiler.compile_specific_html_files(&html_files, &parser) {
486
+ Ok(stats) => {
487
+ total_stats.files_compiled = stats.files_compiled;
488
+ total_stats.internal_components_total = stats.internal_components_total;
489
+ total_stats.external_components_total = stats.external_components_total;
490
+ total_stats.internal_components_unique = stats.internal_components_unique;
491
+ total_stats.external_components_unique = stats.external_components_unique;
492
+ total_stats.compile_time_ms = stats.compile_time_ms;
493
+
494
+ // Generate manifests for affected HTML files
495
+ if !config.runtime_as_is {
496
+ match watch_compiler.generate_manifests_for_files(&html_files) {
497
+ Ok(manifest_stats) => {
498
+ manifest_stats_result = Some(manifest_stats);
499
+ }
500
+ Err(e) => {
501
+ eprintln!("{}: Manifest generation failed: {}", "Warning".yellow(), e);
502
+ }
503
+ }
504
+ }
505
+ }
506
+ Err(e) => {
507
+ eprintln!("{}: {}", "Error".red(), e);
508
+ eprintln!("Fix the errors and save to retry.");
509
+ println!();
510
+ had_errors = true;
511
+ }
512
+ }
513
+ }
514
+
515
+ // Copy asset files
516
+ if !asset_files.is_empty() && !had_errors {
517
+ match watch_compiler.copy_specific_asset_files(&asset_files) {
518
+ Ok(stats) => {
519
+ total_stats.files_copied = stats.files_copied;
520
+ total_stats.copy_time_ms = stats.copy_time_ms;
521
+ }
522
+ Err(e) => {
523
+ eprintln!("{}: {}", "Error".red(), e);
524
+ eprintln!("Fix the errors and save to retry.");
525
+ println!();
526
+ had_errors = true;
527
+ }
528
+ }
529
+ }
530
+
531
+ if !had_errors {
532
+ // Show what was updated
533
+ if total_stats.files_compiled > 0 {
534
+ println!("{} Compiled {} HTML file{}",
535
+ "✓".green(),
536
+ total_stats.files_compiled,
537
+ if total_stats.files_compiled == 1 { "" } else { "s" }
538
+ );
539
+ }
540
+
541
+ if let Some(manifest_stats) = manifest_stats_result {
542
+ if manifest_stats.pages_processed > 0 {
543
+ println!("{} Generated {} manifest{}",
544
+ "✓".green(),
545
+ manifest_stats.pages_processed,
546
+ if manifest_stats.pages_processed == 1 { "" } else { "s" }
547
+ );
548
+ }
549
+ }
550
+
551
+ if total_stats.files_copied > 0 {
552
+ println!("{} Copied {} asset file{}",
553
+ "✓".green(),
554
+ total_stats.files_copied,
555
+ if total_stats.files_copied == 1 { "" } else { "s" }
556
+ );
557
+ }
558
+
559
+ let elapsed = start.elapsed().as_millis();
560
+ println!("{} Updated in {}ms", "✓".green(), elapsed);
561
+ }
562
+ println!();
563
+ }
564
+ Err(errors) => {
565
+ for error in errors {
566
+ eprintln!("{}: {:?}", "Watch error".red(), error);
567
+ }
568
+ }
569
+ }
570
+ }
571
+ Err(e) => {
572
+ eprintln!("{}: {}", "Channel error".red(), e);
573
+ break;
574
+ }
575
+ }
576
+ }
577
+
578
+ Ok(())
579
+ }