@ape-egg/vibe 2.1.22 → 3.0.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.
Files changed (56) hide show
  1. package/README.md +112 -5
  2. package/boot.js +4 -4
  3. package/component.js +27 -29
  4. package/hot-module-refresh.js +4 -4
  5. package/index.js +26 -17
  6. package/llms.txt +36 -5
  7. package/package.json +20 -14
  8. package/runtime/affected.js +159 -36
  9. package/runtime/cleanup.js +45 -1
  10. package/runtime/component.js +360 -98
  11. package/runtime/conditionals.js +111 -14
  12. package/runtime/debug.js +24 -0
  13. package/runtime/dispatch.js +172 -0
  14. package/runtime/hydrate.js +277 -110
  15. package/runtime/index.js +189 -65
  16. package/runtime/iterate.js +125 -50
  17. package/runtime/iteration-utils.js +59 -8
  18. package/runtime/manifest.js +77 -2
  19. package/runtime/parse.js +81 -11
  20. package/runtime/pre-compiled-iterations.js +19 -6
  21. package/runtime/pre-compiled-manifest.js +13 -4
  22. package/runtime/staging.js +153 -0
  23. package/runtime/state.js +31 -0
  24. package/runtime/tracking.js +173 -0
  25. package/runtime/utils.js +155 -78
  26. package/spa.js +206 -0
  27. package/vibe.css +8 -4
  28. package/CHANGELOG.md +0 -1159
  29. package/ROADMAP.md +0 -397
  30. package/compiler/bin/vibe-compile.js +0 -121
  31. package/compiler/native/.gitkeep +0 -0
  32. package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
  33. package/compiler/native/vibe-compiler-linux-x64 +0 -0
  34. package/compiler/src/Cargo.lock +0 -2023
  35. package/compiler/src/Cargo.toml +0 -38
  36. package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +0 -241
  37. package/compiler/src/compiler/binding_case.rs +0 -88
  38. package/compiler/src/compiler/compile.rs +0 -2522
  39. package/compiler/src/compiler/component_tagger.rs +0 -469
  40. package/compiler/src/compiler/iteration_optimizer.rs +0 -455
  41. package/compiler/src/compiler/js_analyzer.rs +0 -715
  42. package/compiler/src/compiler/manifest_builder.rs +0 -693
  43. package/compiler/src/compiler/mod.rs +0 -15
  44. package/compiler/src/compiler/name_binding_protect.rs +0 -207
  45. package/compiler/src/compiler/reassignment_analyzer.rs +0 -456
  46. package/compiler/src/compiler/state_extractor.rs +0 -263
  47. package/compiler/src/compiler/value_stamper.rs +0 -921
  48. package/compiler/src/compiler/watcher.rs +0 -1147
  49. package/compiler/src/config.rs +0 -239
  50. package/compiler/src/main.rs +0 -347
  51. package/compiler/src/parser/element.rs +0 -96
  52. package/compiler/src/parser/html.rs +0 -1004
  53. package/compiler/src/parser/mod.rs +0 -8
  54. package/runtime/pre-compiled-manifest.test.mjs +0 -58
  55. package/runtime/scope.js +0 -50
  56. package/test-results/.last-run.json +0 -4
@@ -1,1147 +0,0 @@
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
- /// component path -> set of component paths it uses (forward edges, so a
21
- /// component's own deps can be cleared on refresh — the mirror of
22
- /// component_to_components)
23
- component_to_used_components: HashMap<PathBuf, HashSet<PathBuf>>,
24
- }
25
-
26
- impl DependencyGraph {
27
- pub fn new() -> Self {
28
- Self {
29
- component_to_pages: HashMap::new(),
30
- component_to_components: HashMap::new(),
31
- page_to_components: HashMap::new(),
32
- component_to_used_components: HashMap::new(),
33
- }
34
- }
35
-
36
- /// Add a dependency: file (page or component) uses component
37
- pub fn add_dependency(&mut self, file: PathBuf, component: PathBuf, file_is_component: bool) {
38
- if file_is_component {
39
- // Component uses another component
40
- self.component_to_components
41
- .entry(component.clone())
42
- .or_insert_with(HashSet::new)
43
- .insert(file.clone());
44
-
45
- self.component_to_used_components
46
- .entry(file)
47
- .or_insert_with(HashSet::new)
48
- .insert(component);
49
- } else {
50
- // Page uses component
51
- self.component_to_pages
52
- .entry(component.clone())
53
- .or_insert_with(HashSet::new)
54
- .insert(file.clone());
55
-
56
- self.page_to_components
57
- .entry(file)
58
- .or_insert_with(HashSet::new)
59
- .insert(component);
60
- }
61
- }
62
-
63
- /// Replace `file`'s outgoing dependency edges with `deps`. Works for both
64
- /// pages and components, so a newly-added `<component src>` reference (or a
65
- /// removed one) is learned the moment the referrer is saved. This is what lets
66
- /// the watcher pick up files created mid-session: referencing a new component
67
- /// always means editing a referrer, and that edit refreshes the graph here.
68
- pub fn refresh_file(&mut self, file: &Path, deps: HashSet<PathBuf>, file_is_component: bool) {
69
- // Clear the file's old outgoing edges (and their reverse entries) so a
70
- // dependency it no longer uses stops mapping back to it.
71
- let forward = if file_is_component {
72
- &mut self.component_to_used_components
73
- } else {
74
- &mut self.page_to_components
75
- };
76
-
77
- if let Some(old_deps) = forward.remove(file) {
78
- let reverse = if file_is_component {
79
- &mut self.component_to_components
80
- } else {
81
- &mut self.component_to_pages
82
- };
83
- for dep in old_deps {
84
- if let Some(users) = reverse.get_mut(&dep) {
85
- users.remove(file);
86
- }
87
- }
88
- }
89
-
90
- // Add the current edges.
91
- for dep in deps {
92
- self.add_dependency(file.to_path_buf(), dep, file_is_component);
93
- }
94
- }
95
-
96
- /// Get all pages that transitively depend on a component (includes component -> component -> page chains)
97
- pub fn get_all_dependent_pages(&self, component: &Path) -> HashSet<PathBuf> {
98
- let mut all_pages = HashSet::new();
99
- let mut visited_components = HashSet::new();
100
- self.collect_dependent_pages(component, &mut all_pages, &mut visited_components);
101
- all_pages
102
- }
103
-
104
- fn collect_dependent_pages(
105
- &self,
106
- component: &Path,
107
- all_pages: &mut HashSet<PathBuf>,
108
- visited: &mut HashSet<PathBuf>,
109
- ) {
110
- // Avoid infinite loops in circular dependencies
111
- if !visited.insert(component.to_path_buf()) {
112
- return;
113
- }
114
-
115
- // Add pages that directly use this component
116
- if let Some(pages) = self.component_to_pages.get(component) {
117
- all_pages.extend(pages.iter().cloned());
118
- }
119
-
120
- // Recursively add pages that use components that use this component
121
- if let Some(dependent_components) = self.component_to_components.get(component) {
122
- for dep_component in dependent_components {
123
- self.collect_dependent_pages(dep_component, all_pages, visited);
124
- }
125
- }
126
- }
127
-
128
- /// Get the edited component itself plus every component that transitively
129
- /// inlines it. These are exactly the component caches that go stale on an
130
- /// edit: a parent's cached content is the *fully inlined* child, so when the
131
- /// child changes the parent's cache is stale too. Unrelated components keep
132
- /// their cache. The returned set always includes `component` itself.
133
- pub fn get_all_dependent_components(&self, component: &Path) -> HashSet<PathBuf> {
134
- let mut all = HashSet::new();
135
- self.collect_dependent_components(component, &mut all);
136
- all
137
- }
138
-
139
- fn collect_dependent_components(&self, component: &Path, all: &mut HashSet<PathBuf>) {
140
- // insert() == false → already seen: doubles as the cycle guard.
141
- if !all.insert(component.to_path_buf()) {
142
- return;
143
- }
144
-
145
- if let Some(users) = self.component_to_components.get(component) {
146
- for user in users {
147
- self.collect_dependent_components(user, all);
148
- }
149
- }
150
- }
151
- }
152
-
153
- /// Extract component references from HTML content
154
- pub fn extract_component_dependencies(html: &str, components_dir: &Path) -> HashSet<PathBuf> {
155
- let mut deps = HashSet::new();
156
-
157
- // 1. <component src="/components/card.html"> - works in both compiled and runtime modes
158
- let component_tag_re = Regex::new(r#"<component\s+[^>]*src=["']([^"']+)["']"#).unwrap();
159
- for cap in component_tag_re.captures_iter(html) {
160
- if let Some(src) = cap.get(1) {
161
- let src_path = src.as_str().trim_start_matches('/');
162
- deps.insert(PathBuf::from(src_path));
163
- }
164
- }
165
-
166
- // 2. Custom elements: <UserCard> -> /components/user-card.html
167
- let custom_element_re = Regex::new(r"<([A-Z][a-zA-Z0-9]*)[\s>]").unwrap();
168
- for cap in custom_element_re.captures_iter(html) {
169
- if let Some(tag) = cap.get(1) {
170
- let kebab = to_kebab_case(tag.as_str());
171
- let component_path = components_dir.join(format!("{}.html", kebab));
172
- // Store relative to source root
173
- if let Ok(rel_path) = component_path.strip_prefix("/") {
174
- deps.insert(rel_path.to_path_buf());
175
- } else {
176
- deps.insert(component_path);
177
- }
178
- }
179
- }
180
-
181
- deps
182
- }
183
-
184
- /// Convert PascalCase to kebab-case
185
- fn to_kebab_case(s: &str) -> String {
186
- let mut result = String::new();
187
- for (i, ch) in s.chars().enumerate() {
188
- if ch.is_uppercase() && i > 0 {
189
- result.push('-');
190
- }
191
- result.push(ch.to_ascii_lowercase());
192
- }
193
- result
194
- }
195
-
196
- /// Map a component's source path to the normalized key under which its inlined
197
- /// content is cached (e.g. `/components/Sidebar.html`). The cache key is the
198
- /// component's path relative to the source root, with a leading slash — the same
199
- /// form `<component src>` resolves to. Returns None for paths outside the source
200
- /// root (e.g. external URL components, which never go stale on a local edit).
201
- fn component_cache_key(component: &Path, canonical_source: &Path) -> Option<String> {
202
- component
203
- .strip_prefix(canonical_source)
204
- .ok()
205
- .and_then(|rel| rel.to_str())
206
- .map(|rel| format!("/{}", rel.replace('\\', "/")))
207
- }
208
-
209
- /// Cross-process guard: exactly one `vibe compile --watch` per output
210
- /// directory. Two concurrent watchers double-compile every save and race each
211
- /// other's output writes — the loser reads a torn file back and emits a
212
- /// valid-but-empty manifest, so the page hydrates blank with no errors. The
213
- /// lock lives in the OS temp dir keyed by the output path, holds the owner's
214
- /// pid, and a lock whose process is gone is stolen (a killed watcher never
215
- /// unwinds, so Drop alone can't be trusted to clean up).
216
- #[derive(Debug)]
217
- pub struct WatchLock {
218
- path: PathBuf,
219
- }
220
-
221
- impl WatchLock {
222
- /// Deterministic lock path for an output dir, stable whether or not the
223
- /// output exists yet: canonicalize the output itself when possible, else
224
- /// its (existing) parent — so a watcher that locked before the first
225
- /// compile created the output still collides with one that locked after.
226
- fn lock_path_for(output: &Path) -> PathBuf {
227
- use std::collections::hash_map::DefaultHasher;
228
- use std::hash::{Hash, Hasher};
229
-
230
- let canonical = output.canonicalize().unwrap_or_else(|_| {
231
- let parent = output.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
232
- let name = output.file_name().map(PathBuf::from).unwrap_or_default();
233
- parent
234
- .canonicalize()
235
- .unwrap_or_else(|_| parent.to_path_buf())
236
- .join(name)
237
- });
238
-
239
- let mut hasher = DefaultHasher::new();
240
- canonical.hash(&mut hasher);
241
- std::env::temp_dir().join(format!("vibe-watch-{:016x}.lock", hasher.finish()))
242
- }
243
-
244
- pub fn acquire(output: &Path) -> Result<Self, String> {
245
- let path = Self::lock_path_for(output);
246
-
247
- // Two attempts: the second runs only after a stale lock was removed.
248
- for _ in 0..2 {
249
- match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) {
250
- Ok(mut file) => {
251
- use std::io::Write;
252
- let _ = write!(file, "{}", std::process::id());
253
- return Ok(Self { path });
254
- }
255
- Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
256
- let holder = std::fs::read_to_string(&path)
257
- .ok()
258
- .and_then(|s| s.trim().parse::<u32>().ok());
259
- match holder {
260
- Some(pid) if process_alive(pid) => {
261
- return Err(format!(
262
- "another `vibe compile --watch` (pid {}) is already watching this output directory. \
263
- Concurrent watchers race each other's writes and corrupt manifests — stop the other one first. \
264
- (lock: {})",
265
- pid,
266
- path.display()
267
- ));
268
- }
269
- // Dead owner or unreadable lock: stale, steal it.
270
- _ => {
271
- let _ = std::fs::remove_file(&path);
272
- }
273
- }
274
- }
275
- Err(e) => {
276
- return Err(format!(
277
- "failed to create watch lock {}: {}",
278
- path.display(),
279
- e
280
- ));
281
- }
282
- }
283
- }
284
-
285
- Err(format!(
286
- "could not acquire watch lock {} — still held after stale-lock cleanup",
287
- path.display()
288
- ))
289
- }
290
- }
291
-
292
- impl Drop for WatchLock {
293
- fn drop(&mut self) {
294
- let _ = std::fs::remove_file(&self.path);
295
- }
296
- }
297
-
298
- #[cfg(unix)]
299
- fn process_alive(pid: u32) -> bool {
300
- std::process::Command::new("kill")
301
- .arg("-0")
302
- .arg(pid.to_string())
303
- .stdout(std::process::Stdio::null())
304
- .stderr(std::process::Stdio::null())
305
- .status()
306
- .map(|s| s.success())
307
- .unwrap_or(false)
308
- }
309
-
310
- /// Without a portable liveness probe, treat an existing lock as live — failing
311
- /// loudly (with the lock path in the message) beats silently racing.
312
- #[cfg(not(unix))]
313
- fn process_alive(_pid: u32) -> bool {
314
- true
315
- }
316
-
317
- /// Check if a path should be blacklisted based on SKIP_FILES patterns
318
- /// This checks both the filename and all path components relative to source root
319
- fn is_path_blacklisted(path: &Path, source_root: &Path, skip_files: &[String]) -> bool {
320
- let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
321
-
322
- // Check filename against blacklist
323
- if should_skip_path(path, file_name, skip_files) {
324
- return true;
325
- }
326
-
327
- // Check if any path component (relative to source) matches blacklist
328
- if let Ok(relative) = path.strip_prefix(source_root) {
329
- for component in relative.components() {
330
- if let Some(component_str) = component.as_os_str().to_str() {
331
- if should_skip_path(path, component_str, skip_files) {
332
- return true;
333
- }
334
- }
335
- }
336
- }
337
-
338
- false
339
- }
340
-
341
- /// Build dependency graph by scanning all HTML files
342
- pub fn build_dependency_graph(config: &Config) -> std::result::Result<DependencyGraph, std::io::Error> {
343
- let mut graph = DependencyGraph::new();
344
-
345
- // Canonicalize output path for reliable comparison
346
- let canonical_output = config.output.canonicalize()
347
- .unwrap_or_else(|_| config.output.clone());
348
-
349
- // Scan all HTML files in source directory
350
- scan_directory(&config.source, &config.source, &config.components, &canonical_output, &config.skip_files, &mut graph)?;
351
-
352
- Ok(graph)
353
- }
354
-
355
- fn scan_directory(
356
- dir: &Path,
357
- source_root: &Path,
358
- components_dir: &str,
359
- output_dir: &Path,
360
- skip_files: &[String],
361
- graph: &mut DependencyGraph,
362
- ) -> std::result::Result<(), std::io::Error> {
363
- if !dir.is_dir() {
364
- return Ok(());
365
- }
366
-
367
- let components_path = source_root.join(components_dir);
368
-
369
- for entry in std::fs::read_dir(dir)? {
370
- let entry = entry?;
371
- let path = entry.path();
372
- let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
373
-
374
- if path.is_dir() {
375
- // Skip output directory (dynamically check, not hardcoded "compiled")
376
- let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone());
377
- if canonical_path.starts_with(output_dir) {
378
- continue;
379
- }
380
-
381
- // Skip directories using shared skip logic
382
- if should_skip_path(&path, file_name, skip_files) {
383
- continue;
384
- }
385
-
386
- scan_directory(&path, source_root, components_dir, output_dir, skip_files, graph)?;
387
- } else if let Some(ext) = path.extension() {
388
- if ext == "html" {
389
- let html = std::fs::read_to_string(&path)?;
390
- let deps = extract_component_dependencies(&html, &components_path);
391
-
392
- // Check if this file is itself a component
393
- let file_is_component = path.starts_with(&components_path);
394
-
395
- for dep in deps {
396
- let dep_absolute = source_root.join(&dep);
397
- // Canonicalize to handle case-insensitive filesystems (macOS)
398
- let dep_canonical = dep_absolute.canonicalize().unwrap_or(dep_absolute);
399
- graph.add_dependency(path.clone(), dep_canonical, file_is_component);
400
- }
401
- }
402
- }
403
- }
404
-
405
- Ok(())
406
- }
407
-
408
- /// A watcher whose spawning wrapper dies without unwinding (SIGKILL, crashed
409
- /// dev server) is orphaned: it keeps watching and its lock blocks every future
410
- /// `--watch` on the same output. Reparenting is the orphan signal — when the
411
- /// parent pid changes (to init or a reaper), the owner is gone, so release the
412
- /// lock and exit. `std::process::exit` skips Drop, hence the explicit remove.
413
- #[cfg(unix)]
414
- fn exit_when_orphaned(lock_path: PathBuf) {
415
- let parent = std::os::unix::process::parent_id();
416
- std::thread::spawn(move || loop {
417
- std::thread::sleep(Duration::from_secs(2));
418
- if std::os::unix::process::parent_id() != parent {
419
- eprintln!("parent process exited — shutting down watcher");
420
- let _ = std::fs::remove_file(&lock_path);
421
- std::process::exit(0);
422
- }
423
- });
424
- }
425
-
426
- #[cfg(not(unix))]
427
- fn exit_when_orphaned(_lock_path: PathBuf) {}
428
-
429
- /// Start watching for file changes
430
- pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn std::error::Error>> {
431
- // Held for the watcher's whole lifetime; a second watcher on the same
432
- // output exits loudly instead of silently racing this one.
433
- let _watch_lock = WatchLock::acquire(&config.output)?;
434
- exit_when_orphaned(_watch_lock.path.clone());
435
-
436
- println!("{}", "Building dependency graph...".cyan());
437
- let mut graph = build_dependency_graph(&config)?;
438
-
439
- println!("{}", "Running initial compilation...".cyan());
440
- let mut compiler = Compiler::new(config.clone(), verbose);
441
-
442
- match compiler.compile() {
443
- Ok(stats) => {
444
- // Generate manifests BEFORE showing success (unless runtime-as-is is enabled)
445
- let mut manifest_time_ms = 0.0;
446
- let mut manifest_stats_result = None;
447
- if !config.runtime_as_is {
448
- match compiler.generate_manifests() {
449
- Ok(manifest_stats) => {
450
- manifest_time_ms = manifest_stats.total_time_ms;
451
- manifest_stats_result = Some(manifest_stats);
452
- }
453
- Err(e) => {
454
- eprintln!("\n{}: Manifest generation failed: {}", "Warning".yellow(), e);
455
- eprintln!("Compilation succeeded but manifests were not generated.");
456
- }
457
- }
458
- }
459
-
460
- // Show success headline
461
- println!("\n{}", "Initial compilation complete!".green().bold());
462
- println!();
463
-
464
- // Show individual phase timings (same as main.rs output)
465
-
466
- // Show validation time if it happened
467
- if let Some(validation_time) = stats.validation_time_ms {
468
- println!("* Validated components in {:.0}ms", validation_time);
469
- }
470
-
471
- let total_components_unique = stats.internal_components_unique + stats.external_components_unique;
472
-
473
- // Always show components line
474
- if stats.components_as_is {
475
- println!("* Compiled components (0 internal, 0 external) - \"components-as-is\": true");
476
- } else if total_components_unique > 0 {
477
- println!("* Compiled components ({} internal, {} external) in {:.0}ms",
478
- stats.internal_components_unique,
479
- stats.external_components_unique,
480
- stats.components_time_ms
481
- );
482
- }
483
-
484
- if stats.files_compiled > 0 {
485
- println!("* Compiled HTML ({} file{}) in {:.0}ms",
486
- stats.files_compiled,
487
- if stats.files_compiled == 1 { "" } else { "s" },
488
- stats.compile_time_ms
489
- );
490
- }
491
-
492
- // Show manifest stats between HTML and Copied files
493
- if let Some(manifest_stats) = manifest_stats_result {
494
- println!("* Generated manifests ({} file{}, {} skipped) in {:.0}ms",
495
- manifest_stats.pages_processed,
496
- if manifest_stats.pages_processed == 1 { "" } else { "s" },
497
- manifest_stats.pages_skipped,
498
- manifest_stats.total_time_ms
499
- );
500
- }
501
-
502
- if stats.files_copied > 0 {
503
- println!("* Copied files ({} file{}) in {:.0}ms",
504
- stats.files_copied,
505
- if stats.files_copied == 1 { "" } else { "s" },
506
- stats.copy_time_ms
507
- );
508
- }
509
-
510
- // Show node_modules handling
511
- if let Some(nm_time) = stats.node_modules_time_ms {
512
- if stats.node_modules_copied_as_is {
513
- println!("* copied node_modules in {:.0}ms", nm_time);
514
- } else if let Some(ref pkg_manager) = stats.package_manager {
515
- println!("* {} install in {:.0}ms", pkg_manager, nm_time);
516
- }
517
- }
518
-
519
- // Calculate total duration as sum of all individual operations
520
- let total_duration_ms = stats.validation_time_ms.unwrap_or(0.0)
521
- + stats.components_time_ms
522
- + stats.compile_time_ms
523
- + stats.copy_time_ms
524
- + stats.node_modules_time_ms.unwrap_or(0.0)
525
- + manifest_time_ms;
526
-
527
- println!("\n{} in {:.0}ms", "Compiled".green(), total_duration_ms);
528
- println!();
529
- }
530
- Err(e) => {
531
- eprintln!("{}: {}", "Error".red(), e);
532
- eprintln!("Fix the errors and save to retry.");
533
- println!();
534
- }
535
- }
536
-
537
- println!("{}", "Watching for changes... (Ctrl+C to stop)".cyan());
538
- println!();
539
-
540
- // Canonicalize output path for reliable comparison
541
- let canonical_output = config.output.canonicalize()
542
- .unwrap_or_else(|_| config.output.clone());
543
- // Canonical source root: dependency-graph paths are canonical, so map them to
544
- // cache keys against the same base.
545
- let canonical_source = config.source.canonicalize()
546
- .unwrap_or_else(|_| config.source.clone());
547
-
548
- // Keep compiler and parser alive to reuse component cache across incremental compilations
549
- let mut watch_compiler = Compiler::new(config.clone(), false);
550
- // Carry the initial compile's warm component cache into the watcher so the
551
- // first edit is already incremental (only the edited subtree re-expands).
552
- watch_compiler.adopt_component_cache(&mut compiler);
553
- let mut parser = {
554
- use crate::parser::HtmlParser;
555
- let mut p = HtmlParser::new(config.components_path());
556
- if let Err(e) = p.load_elements() {
557
- eprintln!("{}: Failed to load parser: {}", "Error".red(), e);
558
- return Err(Box::new(e));
559
- }
560
- p
561
- };
562
-
563
- let (tx, rx) = channel();
564
- let mut debouncer = new_debouncer(Duration::from_millis(100), None, tx)?;
565
-
566
- debouncer.watcher().watch(&config.source, RecursiveMode::Recursive)?;
567
-
568
- loop {
569
- match rx.recv() {
570
- Ok(result) => {
571
- match result {
572
- Ok(events) => {
573
- // Collect unique paths from events
574
- let mut changed_paths: HashSet<PathBuf> = HashSet::new();
575
-
576
- for event in events {
577
- for path in &event.paths {
578
- // Skip blacklisted files/directories (check entire path, not just filename)
579
- if is_path_blacklisted(path, &config.source, &config.skip_files) {
580
- continue;
581
- }
582
-
583
- if let Some(ext) = path.extension() {
584
- if ext == "html" || ext == "css" || ext == "js" {
585
- changed_paths.insert(path.clone());
586
- }
587
- }
588
- }
589
- }
590
-
591
- if changed_paths.is_empty() {
592
- continue;
593
- }
594
-
595
- // Determine what needs recompiling
596
- let mut pages_to_recompile: HashSet<PathBuf> = HashSet::new();
597
- // Component caches that go stale this batch: each edited
598
- // component plus the ancestors that inline it. Everything
599
- // else stays cached and is reused.
600
- let mut stale_component_keys: HashSet<String> = HashSet::new();
601
- // Changed component files to re-mirror into the output —
602
- // runtime-fetched components are served from that mirror,
603
- // so it must track every edit/deletion even when no page
604
- // inlines the component (zero graph dependents).
605
- let mut components_to_mirror: Vec<PathBuf> = Vec::new();
606
-
607
- for path in &changed_paths {
608
- // Skip files in output directory (avoid infinite loop)
609
- let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone());
610
- if canonical_path.starts_with(&canonical_output) {
611
- continue;
612
- }
613
-
614
- // Skip blacklisted files/directories
615
- if is_path_blacklisted(path, &config.source, &config.skip_files) {
616
- continue;
617
- }
618
-
619
- let relative_path = path.strip_prefix(&config.source)
620
- .unwrap_or(path);
621
-
622
- // Check if it's a component
623
- if path.starts_with(&config.source.join(&config.components)) {
624
- // Component changed - recompile all transitively dependent pages
625
- // Canonicalize to match how dependencies were stored (handles case sensitivity)
626
- let path_canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
627
-
628
- // The output's components mirror tracks every change,
629
- // dependents or not: a component nothing inlines is
630
- // still fetched from the mirror at runtime
631
- // (`<component src="@[page.src]">`, iter-prop roots).
632
- if path.exists() {
633
- println!("{} {} changed", "[watch]".cyan(), relative_path.display());
634
- } else {
635
- println!("{} {} deleted", "[watch]".yellow(), relative_path.display());
636
- }
637
- components_to_mirror.push(path.clone());
638
-
639
- // Refresh this component's own dependency edges so a
640
- // newly-added <component src> (e.g. a child file created
641
- // mid-session) is learned. Without this the new child maps
642
- // to zero dependent pages and editing it is a silent no-op.
643
- if path.exists() {
644
- if let Ok(html) = std::fs::read_to_string(path) {
645
- let components_path = config.source.join(&config.components);
646
- let deps: HashSet<PathBuf> =
647
- extract_component_dependencies(&html, &components_path)
648
- .into_iter()
649
- .map(|dep| {
650
- let dep_absolute = config.source.join(&dep);
651
- dep_absolute.canonicalize().unwrap_or(dep_absolute)
652
- })
653
- .collect();
654
- graph.refresh_file(path, deps, true);
655
- }
656
- }
657
-
658
- let dependent_pages = graph.get_all_dependent_pages(&path_canonical);
659
- if !dependent_pages.is_empty() {
660
- // Invalidate only the edited component and the
661
- // components whose cached inlined content embeds it
662
- // (its ancestors). Every other component stays cached,
663
- // so each affected page re-expands just the changed
664
- // subtree instead of its whole component tree.
665
- for stale in graph.get_all_dependent_components(&path_canonical) {
666
- if let Some(key) = component_cache_key(&stale, &canonical_source) {
667
- stale_component_keys.insert(key);
668
- }
669
- }
670
-
671
- // Reload the changed component in the parser's element cache
672
- // (used for custom element syntax like <Layout>)
673
- if path.exists() {
674
- let _ = parser.reload_element(path);
675
- }
676
-
677
- pages_to_recompile.extend(dependent_pages);
678
- }
679
- } else if let Some(ext) = path.extension() {
680
- if ext == "html" {
681
- // Check if file still exists (handle deletions)
682
- if !path.exists() {
683
- // File was deleted - clean up dependency graph
684
- println!("{} {} deleted", "[watch]".yellow(), relative_path.display());
685
-
686
- if let Some(old_deps) = graph.page_to_components.remove(path) {
687
- for dep in old_deps {
688
- if let Some(pages) = graph.component_to_pages.get_mut(&dep) {
689
- pages.remove(path);
690
- }
691
- }
692
- }
693
-
694
- // Delete corresponding compiled output
695
- let output_path = canonical_output.join(relative_path);
696
- if output_path.exists() {
697
- if let Err(e) = std::fs::remove_file(&output_path) {
698
- eprintln!("{}: Failed to delete {}: {}", "Warning".yellow(), output_path.display(), e);
699
- }
700
- }
701
-
702
- // Delete corresponding manifest
703
- let manifest_path = canonical_output.join("vibe-hyperspeed").join(format!("{}.manifest.js", relative_path.display()));
704
- if manifest_path.exists() {
705
- if let Err(e) = std::fs::remove_file(&manifest_path) {
706
- eprintln!("{}: Failed to delete manifest {}: {}", "Warning".yellow(), manifest_path.display(), e);
707
- }
708
- }
709
-
710
- continue;
711
- }
712
-
713
- // Page changed - recompile just this page
714
- println!("{} {} changed", "[watch]".cyan(), relative_path.display());
715
- pages_to_recompile.insert(path.clone());
716
-
717
- // Rebuild dependencies for this page (shared with the
718
- // component path via refresh_file).
719
- if let Ok(html) = std::fs::read_to_string(path) {
720
- let components_path = config.source.join(&config.components);
721
- let deps: HashSet<PathBuf> =
722
- extract_component_dependencies(&html, &components_path)
723
- .into_iter()
724
- .map(|dep| {
725
- let dep_absolute = config.source.join(&dep);
726
- dep_absolute.canonicalize().unwrap_or(dep_absolute)
727
- })
728
- .collect();
729
- graph.refresh_file(path, deps, false);
730
- }
731
- }
732
- }
733
- }
734
-
735
- // Separate HTML files (from dependency graph) and CSS/JS assets (from changed_paths)
736
- // Filter out deleted files
737
- let html_files: Vec<PathBuf> = pages_to_recompile.into_iter()
738
- .filter(|p| p.exists())
739
- .collect();
740
-
741
- let mut asset_files: Vec<PathBuf> = Vec::new();
742
- for path in &changed_paths {
743
- // Skip files in output directory
744
- let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone());
745
- if canonical_path.starts_with(&canonical_output) {
746
- continue;
747
- }
748
-
749
- // Skip blacklisted files/directories
750
- if is_path_blacklisted(path, &config.source, &config.skip_files) {
751
- continue;
752
- }
753
-
754
- // Handle deleted asset files
755
- if !path.exists() {
756
- if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
757
- if ext == "css" || ext == "js" {
758
- let relative_path = path.strip_prefix(&config.source).unwrap_or(path);
759
- let output_path = canonical_output.join(relative_path);
760
- if output_path.exists() {
761
- if let Err(e) = std::fs::remove_file(&output_path) {
762
- eprintln!("{}: Failed to delete {}: {}", "Warning".yellow(), output_path.display(), e);
763
- } else {
764
- println!("{} {} deleted", "[watch]".yellow(), relative_path.display());
765
- }
766
- }
767
- }
768
- }
769
- continue;
770
- }
771
-
772
- if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
773
- if ext == "css" || ext == "js" {
774
- asset_files.push(path.clone());
775
- }
776
- }
777
- }
778
-
779
- if html_files.is_empty() && asset_files.is_empty() && components_to_mirror.is_empty() {
780
- continue;
781
- }
782
-
783
- let start = std::time::Instant::now();
784
-
785
- // Use persistent compiler and parser (cache is preserved)
786
- let mut total_stats = super::CompileStats {
787
- files_compiled: 0,
788
- files_copied: 0,
789
- internal_components_unique: 0,
790
- external_components_unique: 0,
791
- internal_components_total: 0,
792
- external_components_total: 0,
793
- compile_time_ms: 0.0,
794
- copy_time_ms: 0.0,
795
- components_time_ms: 0.0,
796
- validation_time_ms: None,
797
- node_modules_time_ms: None,
798
- package_manager: None,
799
- node_modules_copied_as_is: false,
800
- components_as_is: config.components_as_is,
801
- };
802
-
803
- let mut manifest_stats_result = None;
804
- let mut had_errors = false;
805
-
806
- // Compile HTML files
807
- if !html_files.is_empty() {
808
- // Drop only the stale component caches (edited components +
809
- // their inlining ancestors); unchanged components are reused.
810
- // A page-only edit invalidates nothing here — its components
811
- // are still valid — so the whole cache is reused as-is.
812
- let invalidated = watch_compiler.invalidate_components(&stale_component_keys);
813
- if invalidated > 0 {
814
- println!("{} {} component{} re-expanded, {} reused from cache",
815
- "↻".cyan(),
816
- invalidated,
817
- if invalidated == 1 { "" } else { "s" },
818
- watch_compiler.cached_component_count(),
819
- );
820
- }
821
-
822
- match watch_compiler.compile_specific_html_files(&html_files, &parser) {
823
- Ok(stats) => {
824
- total_stats.files_compiled = stats.files_compiled;
825
- total_stats.internal_components_total = stats.internal_components_total;
826
- total_stats.external_components_total = stats.external_components_total;
827
- total_stats.internal_components_unique = stats.internal_components_unique;
828
- total_stats.external_components_unique = stats.external_components_unique;
829
- total_stats.compile_time_ms = stats.compile_time_ms;
830
-
831
- // Generate manifests for affected HTML files
832
- if !config.runtime_as_is {
833
- match watch_compiler.generate_manifests_for_files(&html_files) {
834
- Ok(manifest_stats) => {
835
- manifest_stats_result = Some(manifest_stats);
836
- }
837
- Err(e) => {
838
- eprintln!("{}: Manifest generation failed: {}", "Warning".yellow(), e);
839
- }
840
- }
841
- }
842
- }
843
- Err(e) => {
844
- eprintln!("{}: {}", "Error".red(), e);
845
- eprintln!("Fix the errors and save to retry.");
846
- println!();
847
- had_errors = true;
848
- }
849
- }
850
- }
851
-
852
- // Copy asset files
853
- if !asset_files.is_empty() && !had_errors {
854
- match watch_compiler.copy_specific_asset_files(&asset_files) {
855
- Ok(stats) => {
856
- total_stats.files_copied = stats.files_copied;
857
- total_stats.copy_time_ms = stats.copy_time_ms;
858
- }
859
- Err(e) => {
860
- eprintln!("{}: {}", "Error".red(), e);
861
- eprintln!("Fix the errors and save to retry.");
862
- println!();
863
- had_errors = true;
864
- }
865
- }
866
- }
867
-
868
- // Keep the output's components mirror in sync — changed
869
- // components reach it even with zero dependent pages, and
870
- // deleted ones leave it (see mirror_component_files).
871
- if !components_to_mirror.is_empty() && !had_errors {
872
- match watch_compiler.mirror_component_files(&components_to_mirror) {
873
- Ok(copied) => {
874
- total_stats.files_copied += copied;
875
- }
876
- Err(e) => {
877
- eprintln!("{}: {}", "Error".red(), e);
878
- eprintln!("Fix the errors and save to retry.");
879
- println!();
880
- had_errors = true;
881
- }
882
- }
883
- }
884
-
885
- if !had_errors {
886
- // Show what was updated
887
- if total_stats.files_compiled > 0 {
888
- println!("{} Compiled {} HTML file{}",
889
- "✓".green(),
890
- total_stats.files_compiled,
891
- if total_stats.files_compiled == 1 { "" } else { "s" }
892
- );
893
- }
894
-
895
- if let Some(manifest_stats) = manifest_stats_result {
896
- if manifest_stats.pages_processed > 0 {
897
- println!("{} Generated {} manifest{}",
898
- "✓".green(),
899
- manifest_stats.pages_processed,
900
- if manifest_stats.pages_processed == 1 { "" } else { "s" }
901
- );
902
- }
903
- }
904
-
905
- if total_stats.files_copied > 0 {
906
- println!("{} Copied {} asset file{}",
907
- "✓".green(),
908
- total_stats.files_copied,
909
- if total_stats.files_copied == 1 { "" } else { "s" }
910
- );
911
- }
912
-
913
- let elapsed = start.elapsed().as_millis();
914
- println!("{} Updated in {}ms", "✓".green(), elapsed);
915
- }
916
- println!();
917
- }
918
- Err(errors) => {
919
- for error in errors {
920
- eprintln!("{}: {:?}", "Watch error".red(), error);
921
- }
922
- }
923
- }
924
- }
925
- Err(e) => {
926
- eprintln!("{}: {}", "Channel error".red(), e);
927
- break;
928
- }
929
- }
930
- }
931
-
932
- Ok(())
933
- }
934
-
935
- #[cfg(test)]
936
- mod tests {
937
- use super::*;
938
-
939
- // leaf <- mid <- layout <- page, with `sibling` an unrelated component that
940
- // layout also uses. Editing `leaf` invalidates the cached, fully-inlined
941
- // content of leaf and every component whose cache embeds it (mid, layout) —
942
- // but NOT `sibling` (its cache never contained leaf) and NOT pages (they are
943
- // recompiled, not cache entries).
944
- fn chain_graph() -> (DependencyGraph, [PathBuf; 5]) {
945
- let leaf = PathBuf::from("/src/components/AccountProgression.html");
946
- let mid = PathBuf::from("/src/components/Sidebar.html");
947
- let layout = PathBuf::from("/src/components/Layout.html");
948
- let sibling = PathBuf::from("/src/components/Topbar.html");
949
- let page = PathBuf::from("/src/pages/index.html");
950
-
951
- let mut graph = DependencyGraph::new();
952
- graph.add_dependency(mid.clone(), leaf.clone(), true); // mid uses leaf
953
- graph.add_dependency(layout.clone(), mid.clone(), true); // layout uses mid
954
- graph.add_dependency(layout.clone(), sibling.clone(), true); // layout uses sibling
955
- graph.add_dependency(page.clone(), layout.clone(), false); // page uses layout
956
-
957
- (graph, [leaf, mid, layout, sibling, page])
958
- }
959
-
960
- #[test]
961
- fn dependent_components_are_just_the_stale_ancestor_chain() {
962
- let (graph, [leaf, mid, layout, sibling, page]) = chain_graph();
963
-
964
- let stale = graph.get_all_dependent_components(&leaf);
965
-
966
- // The edited component itself, plus every component that inlines it.
967
- assert!(stale.contains(&leaf), "leaf itself must be invalidated");
968
- assert!(stale.contains(&mid), "mid inlines leaf — stale");
969
- assert!(stale.contains(&layout), "layout transitively inlines leaf — stale");
970
- // A sibling that does not contain leaf keeps its cache.
971
- assert!(!stale.contains(&sibling), "sibling never inlined leaf — must stay cached");
972
- // Pages are recompiled, not component-cache entries.
973
- assert!(!stale.contains(&page), "pages are not in the component set");
974
- assert_eq!(stale.len(), 3, "exactly leaf + mid + layout go stale");
975
- }
976
-
977
- #[test]
978
- fn editing_a_leaf_invalidates_far_fewer_than_the_dependent_pages() {
979
- let (graph, [leaf, _mid, _layout, _sibling, _page]) = chain_graph();
980
-
981
- // dependent-pages drives which output files recompile; dependent-
982
- // components drives which caches to drop — the minimal stale set, which
983
- // never includes unrelated siblings.
984
- let stale_components = graph.get_all_dependent_components(&leaf);
985
- let dependent_pages = graph.get_all_dependent_pages(&leaf);
986
-
987
- assert_eq!(dependent_pages.len(), 1, "one page inlines leaf");
988
- assert!(stale_components.iter().all(|c| c.starts_with("/src/components")));
989
- }
990
-
991
- // The new-file bug: a component created mid-session is referenced by editing
992
- // an existing component (e.g. Layout adds <component src="SeasonDowntime">).
993
- // The watcher must refresh the edited component's own deps so that edge is
994
- // learned — otherwise editing the new component maps to zero pages and is a
995
- // silent no-op.
996
- #[test]
997
- fn refreshing_a_component_learns_newly_added_child_references() {
998
- let layout = PathBuf::from("/src/components/Layout.html");
999
- let page = PathBuf::from("/src/pages/index.html");
1000
- let downtime = PathBuf::from("/src/components/SeasonDowntime.html");
1001
-
1002
- let mut graph = DependencyGraph::new();
1003
- graph.add_dependency(page.clone(), layout.clone(), false); // page uses layout
1004
-
1005
- // Brand-new component nobody references yet.
1006
- assert!(
1007
- graph.get_all_dependent_pages(&downtime).is_empty(),
1008
- "nothing uses the new component yet"
1009
- );
1010
-
1011
- // Layout is edited to add <component src="SeasonDowntime">. The watcher
1012
- // refreshes layout's deps, which must learn the layout -> downtime edge.
1013
- let mut new_deps = HashSet::new();
1014
- new_deps.insert(downtime.clone());
1015
- graph.refresh_file(&layout, new_deps, true);
1016
-
1017
- // Now editing the new component must map back to the page that inlines it.
1018
- assert!(
1019
- graph.get_all_dependent_pages(&downtime).contains(&page),
1020
- "after layout learns the new component, editing it must recompile the page"
1021
- );
1022
- }
1023
-
1024
- // refresh_file replaces a file's edges, so a dependency it no longer uses is
1025
- // dropped (no phantom recompiles of files that reference the removed dep).
1026
- #[test]
1027
- fn refreshing_a_file_drops_stale_dependencies() {
1028
- let page = PathBuf::from("/src/pages/index.html");
1029
- let old = PathBuf::from("/src/components/Old.html");
1030
- let new = PathBuf::from("/src/components/New.html");
1031
-
1032
- let mut graph = DependencyGraph::new();
1033
- graph.add_dependency(page.clone(), old.clone(), false);
1034
- assert!(graph.get_all_dependent_pages(&old).contains(&page));
1035
-
1036
- // Page edited: now uses `new` instead of `old`.
1037
- let mut deps = HashSet::new();
1038
- deps.insert(new.clone());
1039
- graph.refresh_file(&page, deps, false);
1040
-
1041
- assert!(
1042
- graph.get_all_dependent_pages(&new).contains(&page),
1043
- "new dependency is learned"
1044
- );
1045
- assert!(
1046
- !graph.get_all_dependent_pages(&old).contains(&page),
1047
- "stale dependency is dropped"
1048
- );
1049
- }
1050
-
1051
- // Same as above but for a component's own deps (component -> component edges),
1052
- // which previously had no forward tracking to clear.
1053
- #[test]
1054
- fn refreshing_a_component_drops_stale_child_references() {
1055
- let page = PathBuf::from("/src/pages/index.html");
1056
- let parent = PathBuf::from("/src/components/Parent.html");
1057
- let old_child = PathBuf::from("/src/components/OldChild.html");
1058
- let new_child = PathBuf::from("/src/components/NewChild.html");
1059
-
1060
- let mut graph = DependencyGraph::new();
1061
- graph.add_dependency(page.clone(), parent.clone(), false); // page uses parent
1062
- graph.add_dependency(parent.clone(), old_child.clone(), true); // parent uses old_child
1063
- assert!(graph.get_all_dependent_pages(&old_child).contains(&page));
1064
-
1065
- // Parent edited: swaps old_child for new_child.
1066
- let mut deps = HashSet::new();
1067
- deps.insert(new_child.clone());
1068
- graph.refresh_file(&parent, deps, true);
1069
-
1070
- assert!(
1071
- graph.get_all_dependent_pages(&new_child).contains(&page),
1072
- "new child reference is learned transitively"
1073
- );
1074
- assert!(
1075
- !graph.get_all_dependent_pages(&old_child).contains(&page),
1076
- "stale child reference is dropped"
1077
- );
1078
- }
1079
-
1080
- fn lock_test_output(name: &str) -> PathBuf {
1081
- let out = std::env::temp_dir().join(format!("vibe_watch_lock_{}_out", name));
1082
- let _ = std::fs::remove_dir_all(&out);
1083
- std::fs::create_dir_all(&out).unwrap();
1084
- // A previous crashed test run may have left a lock behind.
1085
- let _ = std::fs::remove_file(WatchLock::lock_path_for(&out));
1086
- out
1087
- }
1088
-
1089
- // Two concurrent watchers on one output dir double-compile every save and
1090
- // race each other's writes (torn manifest reads → blank pages). The second
1091
- // watcher must refuse to start while the first holds the lock.
1092
- #[test]
1093
- fn second_watch_lock_on_same_output_fails_while_held() {
1094
- let out = lock_test_output("same");
1095
-
1096
- let first = WatchLock::acquire(&out).expect("first lock acquires");
1097
- let second = WatchLock::acquire(&out);
1098
- let msg = second.expect_err("second watcher on the same output must fail loudly");
1099
- assert!(
1100
- msg.contains("vibe compile --watch"),
1101
- "error should explain the conflict: {msg}"
1102
- );
1103
-
1104
- drop(first);
1105
- WatchLock::acquire(&out).expect("released lock can be re-acquired");
1106
- }
1107
-
1108
- // A watcher killed without unwinding (Ctrl+C, SIGTERM from dev tooling)
1109
- // leaves its lock file behind; the pid inside is dead, so the next watcher
1110
- // steals the lock instead of being locked out forever.
1111
- #[test]
1112
- fn stale_lock_from_dead_process_is_stolen() {
1113
- let out = lock_test_output("stale");
1114
-
1115
- // No live process can have this pid (pid_max is 99998 on macOS,
1116
- // ≤ 4194304 on Linux).
1117
- std::fs::write(WatchLock::lock_path_for(&out), "4294967295").unwrap();
1118
-
1119
- WatchLock::acquire(&out).expect("stale lock from a dead process must be stolen");
1120
- }
1121
-
1122
- #[test]
1123
- fn locks_on_different_outputs_do_not_conflict() {
1124
- let out_a = lock_test_output("indep_a");
1125
- let out_b = lock_test_output("indep_b");
1126
-
1127
- let _a = WatchLock::acquire(&out_a).expect("lock a");
1128
- WatchLock::acquire(&out_b).expect("an unrelated output dir must not be blocked");
1129
- }
1130
-
1131
- #[test]
1132
- fn cache_key_is_source_relative_with_leading_slash() {
1133
- let source = PathBuf::from("/proj/src");
1134
- // Must match the key form fetch_component_recursive stores.
1135
- assert_eq!(
1136
- component_cache_key(&PathBuf::from("/proj/src/components/Sidebar.html"), &source)
1137
- .as_deref(),
1138
- Some("/components/Sidebar.html"),
1139
- );
1140
- // A path outside the source root (e.g. an external URL component's
1141
- // resolved path) yields no key, so it's never wrongly invalidated.
1142
- assert_eq!(
1143
- component_cache_key(&PathBuf::from("/elsewhere/x.html"), &source),
1144
- None,
1145
- );
1146
- }
1147
- }