@ape-egg/vibe-compiler 4.3.25 → 4.3.26

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.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe-compiler",
3
- "version": "4.3.25",
3
+ "version": "4.3.26",
4
4
  "type": "module",
5
5
  "description": "Optional Rust compiler for Vibe — pre-renders, inlines components, and optimizes iterations. The runtime never needs it.",
6
6
  "homepage": "https://vibe.korte.kim",
package/src/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vibe-compiler"
3
- version = "4.3.25"
3
+ version = "4.3.26"
4
4
  edition = "2021"
5
5
  description = "Vibe framework compiler - compiles Vibe source files into optimized output"
6
6
  authors = ["Kim Korte"]
@@ -1,7 +1,6 @@
1
1
  use std::fs;
2
2
  use std::path::{Path, PathBuf};
3
- use std::process::Command;
4
- use std::time::Instant;
3
+ use std::time::{Duration, Instant, SystemTime};
5
4
  use std::collections::{BTreeMap, HashSet, HashMap};
6
5
  use thiserror::Error;
7
6
  use colored::Colorize;
@@ -39,17 +38,39 @@ fn manifest_url_path(relative_path: &str, root: Option<&str>) -> String {
39
38
  .join("/")
40
39
  }
41
40
 
42
- /// Write via a same-directory temp file + rename, so no reader — the dev
43
- /// server, the browser, another compiler process — can ever observe a
44
- /// partially-written file. Rename is atomic on POSIX; the temp name carries the
45
- /// pid so two processes writing the same target never share a temp file.
46
- pub(crate) fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
41
+ /// Overwrite the output file in place. A temp file + rename would keep a torn
42
+ /// file from ever being read, but the temp file itself vanishes mid-build, and
43
+ /// a dev server that lists the output and then stats each entry (wrangler dev)
44
+ /// dies on the one that is gone. A torn read is fixed by the next reload.
45
+ ///
46
+ /// A file that shares its bytes with another path — a hard link into a package
47
+ /// manager's cache, as bun installs, or a symlink — is replaced through a
48
+ /// rename instead, since writing through it would rewrite that other path too.
49
+ pub(crate) fn write_in_place(path: &Path, contents: impl AsRef<[u8]>) -> std::io::Result<()> {
50
+ if !shares_bytes(path) {
51
+ return fs::write(path, contents);
52
+ }
47
53
  let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("out");
48
54
  let tmp = path.with_file_name(format!(".{}.{}.vibe-tmp", file_name, std::process::id()));
49
55
  fs::write(&tmp, contents)?;
50
56
  fs::rename(&tmp, path)
51
57
  }
52
58
 
59
+ fn copy_in_place(src: &Path, dest: &Path) -> std::io::Result<()> {
60
+ write_in_place(dest, fs::read(src)?)
61
+ }
62
+
63
+ #[cfg(unix)]
64
+ fn shares_bytes(path: &Path) -> bool {
65
+ use std::os::unix::fs::MetadataExt;
66
+ fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_symlink() || meta.nlink() > 1)
67
+ }
68
+
69
+ #[cfg(not(unix))]
70
+ fn shares_bytes(path: &Path) -> bool {
71
+ fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_symlink())
72
+ }
73
+
53
74
  // =============================================================================
54
75
  // MIRROR_MODE: Copy asset files from source to output as-is, preserving
55
76
  // directory structure. HTML files are compiled separately.
@@ -326,10 +347,8 @@ pub enum CompileError {
326
347
  },
327
348
  #[error("Parse error: {0}")]
328
349
  ParseError(#[from] crate::parser::html::ParseError),
329
- #[error("Failed to execute command: {0}")]
330
- CommandError(String),
331
- #[error("Package manager not found")]
332
- PackageManagerNotFound,
350
+ #[error("Failed to copy packages: {0}")]
351
+ ModulesError(String),
333
352
  #[error("Component name '{component_name}' in '{file_path}' conflicts with reserved element. Component filenames cannot match standard HTML elements (case-insensitive). Use PascalCase (e.g., 'Nav.html' instead of 'nav.html') to avoid conflicts.")]
334
353
  ReservedComponentName {
335
354
  component_name: String,
@@ -351,7 +370,6 @@ pub struct CompileStats {
351
370
  pub components_time_ms: f64,
352
371
  pub validation_time_ms: Option<f64>,
353
372
  pub node_modules_time_ms: Option<f64>,
354
- pub package_manager: Option<String>,
355
373
  pub node_modules_copied_as_is: bool,
356
374
  pub components_as_is: bool,
357
375
  }
@@ -630,6 +648,8 @@ pub struct Compiler {
630
648
  /// disk read-back — so a concurrent writer rewriting the output directory
631
649
  /// (e.g. a second compiler) can't feed a torn read into a manifest.
632
650
  compiled_html: HashMap<PathBuf, String>,
651
+ /// When the last full compile began; output older than it is stale.
652
+ build_start: SystemTime,
633
653
  }
634
654
 
635
655
  impl Compiler {
@@ -642,6 +662,7 @@ impl Compiler {
642
662
  unique_components: HashSet::new(),
643
663
  component_cache: HashMap::new(),
644
664
  compiled_html: HashMap::new(),
665
+ build_start: SystemTime::now(),
645
666
  }
646
667
  }
647
668
 
@@ -683,7 +704,6 @@ impl Compiler {
683
704
  components_time_ms: 0.0,
684
705
  validation_time_ms: None,
685
706
  node_modules_time_ms: None,
686
- package_manager: None,
687
707
  node_modules_copied_as_is: false,
688
708
  components_as_is: self.config.components_as_is,
689
709
  };
@@ -727,18 +747,10 @@ impl Compiler {
727
747
  println!(" HTML validation completed successfully");
728
748
  }
729
749
 
730
- // Clean output directory if not in no-clean mode
731
- if !self.config.no_clean && self.config.output.exists() {
732
- if self.verbose {
733
- println!(" Cleaning output directory: {}", self.config.output.display());
734
- }
735
- fs::remove_dir_all(&self.config.output).map_err(|e| {
736
- CompileError::WriteError {
737
- path: self.config.output.display().to_string(),
738
- source: e,
739
- }
740
- })?;
741
- }
750
+ // Output is overwritten in place and stale files are removed only at
751
+ // the end (remove_stale_output), so a dev server reading the output
752
+ // never sees it emptied.
753
+ self.build_start = SystemTime::now();
742
754
 
743
755
  // Create output directory
744
756
  if !self.config.output.exists() {
@@ -814,31 +826,16 @@ impl Compiler {
814
826
  }
815
827
  }
816
828
 
817
- // Handle node_modules after successful compilation
829
+ // Packages after successful compilation
818
830
  if self.config.working_dir.join("node_modules").exists() {
831
+ let nm_start = Instant::now();
819
832
  if self.config.node_modules_as_is {
820
- // Copy node_modules as-is (no install, just copy)
821
- let nm_start = Instant::now();
822
- self.copy_node_modules_as_is()?;
823
- stats.node_modules_time_ms = Some(nm_start.elapsed().as_secs_f64() * 1000.0);
824
- stats.node_modules_copied_as_is = true;
825
-
826
- if self.verbose {
827
- println!("\n Copied node_modules as-is");
828
- }
833
+ copy_dir_recursive(&self.config.working_dir.join("node_modules"), &self.config.modules_path())?;
829
834
  } else {
830
- // Use production-only strategy (runs install commands)
831
- let pkg_manager = detect_package_manager(&self.config.working_dir).ok();
832
- stats.package_manager = pkg_manager.clone();
833
-
834
- let nm_start = Instant::now();
835
- self.copy_production_node_modules()?;
836
- stats.node_modules_time_ms = Some(nm_start.elapsed().as_secs_f64() * 1000.0);
837
-
838
- if self.verbose {
839
- println!(" Copied production dependencies");
840
- }
835
+ self.copy_production_modules()?;
841
836
  }
837
+ stats.node_modules_time_ms = Some(nm_start.elapsed().as_secs_f64() * 1000.0);
838
+ stats.node_modules_copied_as_is = self.config.node_modules_as_is;
842
839
  }
843
840
 
844
841
  Ok(stats)
@@ -862,7 +859,6 @@ impl Compiler {
862
859
  components_time_ms: 0.0,
863
860
  validation_time_ms: None,
864
861
  node_modules_time_ms: None,
865
- package_manager: None,
866
862
  node_modules_copied_as_is: false,
867
863
  components_as_is: self.config.components_as_is,
868
864
  };
@@ -945,7 +941,6 @@ impl Compiler {
945
941
  components_time_ms: 0.0,
946
942
  validation_time_ms: None,
947
943
  node_modules_time_ms: None,
948
- package_manager: None,
949
944
  node_modules_copied_as_is: false,
950
945
  components_as_is: self.config.components_as_is,
951
946
  };
@@ -1118,7 +1113,7 @@ impl Compiler {
1118
1113
  manifest_json
1119
1114
  );
1120
1115
 
1121
- atomic_write(&manifest_path, &manifest_js)
1116
+ write_in_place(&manifest_path, &manifest_js)
1122
1117
  .map_err(|e| format!("Failed to write manifest: {}", e))?;
1123
1118
 
1124
1119
  // Stamp the compiled HTML with initial state values for FOUC prevention:
@@ -1143,7 +1138,7 @@ impl Compiler {
1143
1138
  let stamped = stamper.stamp_html(html.to_string())
1144
1139
  .map_err(|e| format!("Failed to stamp HTML: {}", e))?;
1145
1140
 
1146
- atomic_write(html_path, &stamped)
1141
+ write_in_place(html_path, &stamped)
1147
1142
  .map_err(|e| format!("Failed to write stamped HTML: {}", e))?;
1148
1143
  }
1149
1144
 
@@ -1512,7 +1507,7 @@ impl Compiler {
1512
1507
 
1513
1508
  // Write to output
1514
1509
  let output_path = self.get_output_path(path, relative_path)?;
1515
- atomic_write(&output_path, &output).map_err(|e| CompileError::WriteError {
1510
+ write_in_place(&output_path, &output).map_err(|e| CompileError::WriteError {
1516
1511
  path: output_path.display().to_string(),
1517
1512
  source: e,
1518
1513
  })?;
@@ -1693,7 +1688,7 @@ impl Compiler {
1693
1688
  CompileError::CreateDirError(parent.display().to_string())
1694
1689
  })?;
1695
1690
  }
1696
- atomic_write(&fragment_path, &page.fragment).map_err(|e| CompileError::WriteError {
1691
+ write_in_place(&fragment_path, &page.fragment).map_err(|e| CompileError::WriteError {
1697
1692
  path: fragment_path.display().to_string(),
1698
1693
  source: e,
1699
1694
  })?;
@@ -1798,7 +1793,7 @@ impl Compiler {
1798
1793
  };
1799
1794
  let shell_html = inject_vibe_css(shell_html, self.config.disable_vibe_css);
1800
1795
  let shell_path = self.config.output.join("index.html");
1801
- atomic_write(&shell_path, &shell_html).map_err(|e| CompileError::WriteError {
1796
+ write_in_place(&shell_path, &shell_html).map_err(|e| CompileError::WriteError {
1802
1797
  path: shell_path.display().to_string(),
1803
1798
  source: e,
1804
1799
  })?;
@@ -1913,7 +1908,7 @@ impl Compiler {
1913
1908
  fn copy_file(&self, path: &Path, relative_path: &str) -> Result<(), CompileError> {
1914
1909
  let output_path = self.get_output_path(path, relative_path)?;
1915
1910
 
1916
- fs::copy(path, &output_path).map_err(|e| CompileError::WriteError {
1911
+ copy_in_place(path, &output_path).map_err(|e| CompileError::WriteError {
1917
1912
  path: output_path.display().to_string(),
1918
1913
  source: e,
1919
1914
  })?;
@@ -1928,9 +1923,7 @@ impl Compiler {
1928
1923
  /// components_as_is — are served from that mirror at request time. Watch
1929
1924
  /// mode calls this for every changed component so the mirror tracks edits
1930
1925
  /// and deletions even when no page inlines the component (zero graph
1931
- /// dependents). Writes are atomic (same tmp+rename as compiled pages) so a
1932
- /// runtime fetch mid-copy never sees a torn file. Returns how many files
1933
- /// were copied.
1926
+ /// dependents). Returns how many files were copied.
1934
1927
  pub fn mirror_component_files(&self, files: &[PathBuf]) -> Result<usize, CompileError> {
1935
1928
  let mut copied = 0;
1936
1929
  for path in files {
@@ -1947,7 +1940,7 @@ impl Compiler {
1947
1940
  path: path.display().to_string(),
1948
1941
  source: e,
1949
1942
  })?;
1950
- atomic_write(&output_path, &contents).map_err(|e| CompileError::WriteError {
1943
+ write_in_place(&output_path, &contents).map_err(|e| CompileError::WriteError {
1951
1944
  path: output_path.display().to_string(),
1952
1945
  source: e,
1953
1946
  })?;
@@ -2494,120 +2487,96 @@ impl Compiler {
2494
2487
  Ok(())
2495
2488
  }
2496
2489
 
2497
- /// Copy node_modules as-is to output
2498
- fn copy_node_modules_as_is(&self) -> Result<(), CompileError> {
2499
- let src = self.config.working_dir.join("node_modules");
2500
- let dest = self.config.output.join("node_modules");
2501
-
2502
- // Remove existing node_modules in output if it exists
2503
- if dest.exists() {
2504
- fs::remove_dir_all(&dest).map_err(|e| CompileError::WriteError {
2505
- path: dest.display().to_string(),
2506
- source: e,
2507
- })?;
2508
- }
2509
-
2510
- // Copy recursively
2511
- copy_dir_recursive(&src, &dest)?;
2512
-
2513
- Ok(())
2514
- }
2515
-
2516
- /// Install production dependencies directly into output directory
2517
- fn copy_production_node_modules(&self) -> Result<(), CompileError> {
2518
- let pkg_manager = detect_package_manager(&self.config.working_dir)?;
2519
-
2520
- if self.verbose {
2521
- println!("\n Detected package manager: {}", pkg_manager);
2522
- println!(" Installing production dependencies to output...");
2523
- }
2524
-
2525
- // 1. Copy package.json to output directory
2526
- let package_json_src = self.config.working_dir.join("package.json");
2527
- let package_json_dest = self.config.output.join("package.json");
2528
-
2529
- fs::copy(&package_json_src, &package_json_dest).map_err(|e| CompileError::WriteError {
2530
- path: package_json_dest.display().to_string(),
2531
- source: e,
2532
- })?;
2533
-
2534
- // 2. Copy lockfile if it exists (needed for reproducible installs)
2535
- let lockfile_name = match pkg_manager.as_str() {
2536
- "bun" if self.config.working_dir.join("bun.lock").exists() => "bun.lock",
2537
- "bun" => "bun.lockb",
2538
- "pnpm" => "pnpm-lock.yaml",
2539
- "yarn" => "yarn.lock",
2540
- "npm" => "package-lock.json",
2541
- _ => "",
2542
- };
2543
-
2544
- if !lockfile_name.is_empty() {
2545
- let lockfile_src = self.config.working_dir.join(lockfile_name);
2546
- let lockfile_dest = self.config.output.join(lockfile_name);
2547
- if lockfile_src.exists() {
2548
- fs::copy(&lockfile_src, &lockfile_dest).ok();
2490
+ /// Remove whatever the last full compile did not write, then the folders it
2491
+ /// left empty. Run once the build is done — manifests included — so a
2492
+ /// file disappears only when it is stale. File times come from the
2493
+ /// kernel's coarse clock, which trails SystemTime::now(), hence the margin.
2494
+ pub fn remove_stale_output(&self) -> Result<usize, CompileError> {
2495
+ if self.config.no_clean {
2496
+ return Ok(0);
2497
+ }
2498
+ remove_older_than(&self.config.output, self.build_start - Duration::from_secs(1))
2499
+ }
2500
+
2501
+ /// Copy every production dependency, and theirs, from the project's own
2502
+ /// node_modules into the modules folder of the output. Written over what is
2503
+ /// there, never through a temp tree, so the output never loses a file.
2504
+ /// A `modules` folder other than node_modules holds each package under its
2505
+ /// bare name (`modules/vibe`, not `modules/@ape-egg/vibe`): some static
2506
+ /// hosts (Cloudflare) redirect any path holding `@`.
2507
+ fn copy_production_modules(&self) -> Result<(), CompileError> {
2508
+ let installed = self.config.working_dir.join("node_modules");
2509
+ let bare = self.config.modules != "node_modules";
2510
+ let mut pending = dependency_names(&self.config.working_dir.join("package.json"))?;
2511
+ let mut copied: HashMap<String, String> = HashMap::new();
2512
+ while let Some(name) = pending.pop() {
2513
+ let src = installed.join(&name);
2514
+ // A dependency missing at the top level is nested inside its dependent, and copied with it
2515
+ if copied.contains_key(&name) || !src.exists() {
2516
+ continue;
2549
2517
  }
2518
+ let folder = if bare { name.rsplit('/').next().unwrap_or(&name).to_string() } else { name.clone() };
2519
+ if let Some((other, _)) = copied.iter().find(|(_, f)| **f == folder) {
2520
+ return Err(CompileError::ModulesError(format!(
2521
+ "\"{}\" and \"{}\" both land in {}/{} — bare package names must be unique in \"modules\"",
2522
+ other, name, self.config.modules, folder
2523
+ )));
2524
+ }
2525
+ copy_dir_recursive(&src, &self.config.modules_path().join(&folder))?;
2526
+ pending.extend(dependency_names(&src.join("package.json"))?);
2527
+ copied.insert(name, folder);
2550
2528
  }
2551
-
2552
- // 3. Install production dependencies directly in output directory
2553
- run_command(&pkg_manager, &["install", "--production"], &self.config.output)?;
2554
-
2555
- // 4. Cleanup: remove package.json and lockfile from output
2556
- fs::remove_file(&package_json_dest).ok();
2557
- if !lockfile_name.is_empty() {
2558
- let lockfile_dest = self.config.output.join(lockfile_name);
2559
- fs::remove_file(&lockfile_dest).ok();
2560
- }
2561
-
2562
- if self.verbose {
2563
- println!(" Installed production dependencies to output");
2564
- }
2565
-
2566
2529
  Ok(())
2567
2530
  }
2568
2531
  }
2569
2532
 
2570
- /// Detect package manager from lockfiles
2571
- fn detect_package_manager(dir: &Path) -> Result<String, CompileError> {
2572
- if dir.join("bun.lockb").exists() || dir.join("bun.lock").exists() {
2573
- return Ok("bun".to_string());
2574
- }
2575
- if dir.join("pnpm-lock.yaml").exists() {
2576
- return Ok("pnpm".to_string());
2577
- }
2578
- if dir.join("yarn.lock").exists() {
2579
- return Ok("yarn".to_string());
2580
- }
2581
- if dir.join("package-lock.json").exists() {
2582
- return Ok("npm".to_string());
2533
+ /// Remove every file under `dir` last written before `cutoff`, and every
2534
+ /// folder that leaves empty. Returns how many files went.
2535
+ fn remove_older_than(dir: &Path, cutoff: SystemTime) -> Result<usize, CompileError> {
2536
+ let read_error = |e| CompileError::ReadError { path: dir.display().to_string(), source: e };
2537
+ let write_error = |path: &Path, e| CompileError::WriteError { path: path.display().to_string(), source: e };
2538
+ let mut removed = 0;
2539
+ for entry in fs::read_dir(dir).map_err(read_error)? {
2540
+ let entry = entry.map_err(read_error)?;
2541
+ let path = entry.path();
2542
+ let meta = entry.metadata().map_err(read_error)?;
2543
+ if meta.is_dir() {
2544
+ removed += remove_older_than(&path, cutoff)?;
2545
+ if fs::read_dir(&path).map_err(read_error)?.next().is_none() {
2546
+ fs::remove_dir(&path).map_err(|e| write_error(&path, e))?;
2547
+ }
2548
+ } else if meta.modified().map_err(read_error)? < cutoff {
2549
+ fs::remove_file(&path).map_err(|e| write_error(&path, e))?;
2550
+ removed += 1;
2551
+ }
2583
2552
  }
2584
-
2585
- Err(CompileError::PackageManagerNotFound)
2553
+ Ok(removed)
2586
2554
  }
2587
2555
 
2588
- /// Run a package manager command
2589
- fn run_command(pkg_manager: &str, args: &[&str], cwd: &Path) -> Result<(), CompileError> {
2590
- let output = Command::new(pkg_manager)
2591
- .args(args)
2592
- .current_dir(cwd)
2593
- .output()
2594
- .map_err(|e| CompileError::CommandError(format!("{} not found: {}", pkg_manager, e)))?;
2595
-
2596
- if !output.status.success() {
2597
- let stderr = String::from_utf8_lossy(&output.stderr);
2598
- return Err(CompileError::CommandError(format!(
2599
- "{} {} failed: {}",
2600
- pkg_manager,
2601
- args.join(" "),
2602
- stderr
2603
- )));
2604
- }
2605
-
2606
- Ok(())
2556
+ /// Names under `dependencies`, `optionalDependencies` and `peerDependencies`
2557
+ fn dependency_names(package_json: &Path) -> Result<Vec<String>, CompileError> {
2558
+ let content = fs::read_to_string(package_json).map_err(|e| CompileError::ReadError {
2559
+ path: package_json.display().to_string(),
2560
+ source: e,
2561
+ })?;
2562
+ let package: Value = serde_json::from_str(&content)
2563
+ .map_err(|e| CompileError::ModulesError(format!("{}: {}", package_json.display(), e)))?;
2564
+ Ok(["dependencies", "optionalDependencies", "peerDependencies"]
2565
+ .iter()
2566
+ .filter_map(|key| package.get(key).and_then(Value::as_object))
2567
+ .flat_map(|deps| deps.keys().cloned())
2568
+ .collect())
2607
2569
  }
2608
2570
 
2609
2571
  /// Recursively copy a directory
2610
2572
  fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), CompileError> {
2573
+ // Copying into a symlinked folder would write into the folder it points at
2574
+ if fs::symlink_metadata(dest).is_ok_and(|meta| meta.file_type().is_symlink()) {
2575
+ fs::remove_file(dest).map_err(|e| CompileError::WriteError {
2576
+ path: dest.display().to_string(),
2577
+ source: e,
2578
+ })?;
2579
+ }
2611
2580
  if !dest.exists() {
2612
2581
  fs::create_dir_all(dest).map_err(|_| CompileError::CreateDirError(dest.display().to_string()))?;
2613
2582
  }
@@ -2635,7 +2604,7 @@ fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), CompileError> {
2635
2604
  }
2636
2605
  copy_dir_recursive(&path, &dest_path)?;
2637
2606
  } else {
2638
- fs::copy(&path, &dest_path).map_err(|e| CompileError::WriteError {
2607
+ copy_in_place(&path, &dest_path).map_err(|e| CompileError::WriteError {
2639
2608
  path: dest_path.display().to_string(),
2640
2609
  source: e,
2641
2610
  })?;
@@ -2790,17 +2759,16 @@ mod tests {
2790
2759
  }
2791
2760
 
2792
2761
  #[test]
2793
- fn atomic_write_replaces_content_without_leaving_tmp_files() {
2794
- let dir = std::env::temp_dir().join("vibe_atomic_write_test");
2762
+ fn write_in_place_replaces_content_without_leaving_tmp_files() {
2763
+ let dir = std::env::temp_dir().join("vibe_write_in_place_test");
2795
2764
  let _ = fs::remove_dir_all(&dir);
2796
2765
  fs::create_dir_all(&dir).unwrap();
2797
2766
  let target = dir.join("page.html");
2798
2767
 
2799
- atomic_write(&target, "first").unwrap();
2768
+ write_in_place(&target, "first").unwrap();
2800
2769
  assert_eq!(fs::read_to_string(&target).unwrap(), "first");
2801
2770
 
2802
- // Overwriting an existing file goes through the same tmp+rename path.
2803
- atomic_write(&target, "second, longer content").unwrap();
2771
+ write_in_place(&target, "second, longer content").unwrap();
2804
2772
  assert_eq!(fs::read_to_string(&target).unwrap(), "second, longer content");
2805
2773
 
2806
2774
  let leftovers: Vec<String> = fs::read_dir(&dir)
@@ -2812,6 +2780,30 @@ mod tests {
2812
2780
  assert!(leftovers.is_empty(), "temp artifacts left behind: {leftovers:?}");
2813
2781
  }
2814
2782
 
2783
+ // bun installs packages as hard links into its cache, and an output built
2784
+ // by an install holds more of them. Copying a package over such an output
2785
+ // once truncated the cached package, and every project linked to it.
2786
+ #[test]
2787
+ fn copy_in_place_never_writes_through_a_hard_link() {
2788
+ let dir = std::env::temp_dir().join("vibe_copy_in_place_test");
2789
+ let _ = fs::remove_dir_all(&dir);
2790
+ fs::create_dir_all(&dir).unwrap();
2791
+ let (cache, installed, output) = (dir.join("cache.js"), dir.join("installed.js"), dir.join("output.js"));
2792
+ fs::write(&cache, "cached").unwrap();
2793
+ fs::hard_link(&cache, &installed).unwrap();
2794
+ fs::hard_link(&cache, &output).unwrap();
2795
+
2796
+ copy_in_place(&installed, &output).unwrap();
2797
+ assert_eq!(fs::read_to_string(&cache).unwrap(), "cached");
2798
+ assert_eq!(fs::read_to_string(&output).unwrap(), "cached");
2799
+
2800
+ write_in_place(&installed, "newer").unwrap();
2801
+ assert_eq!(fs::read_to_string(&cache).unwrap(), "cached");
2802
+ assert_eq!(fs::read_to_string(&installed).unwrap(), "newer");
2803
+
2804
+ let _ = fs::remove_dir_all(&dir);
2805
+ }
2806
+
2815
2807
  // A minimal on-disk project: source with one page carrying a distinctive
2816
2808
  // binding, empty components dir, output dir sibling. Returns (config, page
2817
2809
  // source path, compiled page output path, manifest path).
@@ -2881,6 +2873,7 @@ mod tests {
2881
2873
  reserved_elements: Vec::new(),
2882
2874
  skip_files: Vec::new(),
2883
2875
  node_modules_as_is: false,
2876
+ modules: "node_modules".to_string(),
2884
2877
  components_as_is: true,
2885
2878
  runtime_as_is: false,
2886
2879
  iterations_as_is: false,
@@ -2970,6 +2963,7 @@ mod tests {
2970
2963
  reserved_elements: Vec::new(),
2971
2964
  skip_files: Vec::new(),
2972
2965
  node_modules_as_is: false,
2966
+ modules: "node_modules".to_string(),
2973
2967
  components_as_is: false,
2974
2968
  runtime_as_is: false,
2975
2969
  iterations_as_is: false,
@@ -3083,6 +3077,7 @@ mod tests {
3083
3077
  reserved_elements: Vec::new(),
3084
3078
  skip_files: Vec::new(),
3085
3079
  node_modules_as_is: false,
3080
+ modules: "node_modules".to_string(),
3086
3081
  components_as_is: false,
3087
3082
  runtime_as_is: false,
3088
3083
  iterations_as_is: false,
@@ -3155,6 +3150,7 @@ mod tests {
3155
3150
  reserved_elements: Vec::new(),
3156
3151
  skip_files: Vec::new(),
3157
3152
  node_modules_as_is: false,
3153
+ modules: "node_modules".to_string(),
3158
3154
  components_as_is: false,
3159
3155
  runtime_as_is: false,
3160
3156
  iterations_as_is: false,
@@ -1,35 +1,20 @@
1
1
  use regex::{Regex, Captures};
2
2
  use serde_json::{Map, Value};
3
3
  use rquickjs::{Context, Runtime};
4
- use std::collections::{HashMap, HashSet};
5
-
6
- /// Render a primitive state value the way an evaluated `@[expr]` would print it:
7
- /// strings/numbers/bools as their text, null as empty. Mirrors `eval_with_state`.
8
- fn primitive_to_text(value: &Value) -> String {
9
- match value {
10
- Value::String(s) => s.clone(),
11
- Value::Number(n) => n.to_string(),
12
- Value::Bool(b) => b.to_string(),
13
- Value::Null => String::new(),
14
- // Constants are primitive-only (see reassignment_analyzer); fall back safely.
15
- other => other.to_string(),
16
- }
17
- }
4
+ use std::collections::HashSet;
18
5
 
19
6
  use crate::constants::{NON_REACTIVE_ELEMENTS, VALUE_ATTRS, DEHYDRATE_ATTR, NAMEBIND_ATTR, DEFER_ATTR_PREFIX};
7
+ use crate::parser::html::decode_entities;
20
8
 
21
-
22
- pub struct ValueStamper<'a> {
23
- state: &'a Value,
9
+ pub struct ValueStamper {
10
+ /// Stamp state plus the global keys reassignment analysis proved constant.
11
+ /// A dynamic global is never in here, so any expression reading one throws
12
+ /// a ReferenceError in QuickJS and its binding stays live for the runtime.
13
+ state: Value,
24
14
  binding_regex: Regex,
25
15
  _runtime: Runtime, // Must be kept alive for context to work
26
16
  context: Context,
27
17
  components_as_is: bool,
28
- /// Global state keys proven constant by reassignment analysis, mapped to the
29
- /// text they stamp to. Only an `@[key]` binding whose expression is EXACTLY
30
- /// one of these keys is stamped from here — never a compound expression — so
31
- /// there is no risk of a dynamic operand leaking into the value.
32
- constant_texts: HashMap<String, String>,
33
18
  /// Selective mode (the SPA shell): unresolvable state is runtime-owned, so
34
19
  /// unresolved attribute markers are kept instead of stripped and a
35
20
  /// conditional whose expression reads an unknown root stays raw (both
@@ -37,47 +22,46 @@ pub struct ValueStamper<'a> {
37
22
  selective: bool,
38
23
  }
39
24
 
40
- impl<'a> ValueStamper<'a> {
41
- pub fn new(state: &'a Value, components_as_is: bool) -> Result<Self, String> {
25
+ impl ValueStamper {
26
+ pub fn new(state: &Value, components_as_is: bool) -> Result<Self, String> {
42
27
  Self::with_constants(state, components_as_is, &Map::new())
43
28
  }
44
29
 
45
30
  pub fn with_constants(
46
- state: &'a Value,
31
+ state: &Value,
47
32
  components_as_is: bool,
48
33
  constants: &Map<String, Value>,
49
34
  ) -> Result<Self, String> {
35
+ let mut state = state.clone();
36
+ state.as_object_mut().expect("stamp state is an object").extend(constants.clone());
37
+
50
38
  let runtime = Runtime::new().map_err(|e| format!("Failed to create QuickJS runtime: {:?}", e))?;
51
39
  let context = Context::full(&runtime).map_err(|e| format!("Failed to create QuickJS context: {:?}", e))?;
52
40
 
53
41
  // Set up state ONCE when creating the stamper (not per expression)
54
42
  context.with(|ctx| -> Result<(), String> {
55
- let state_str = serde_json::to_string(state)
43
+ let state_str = serde_json::to_string(&state)
56
44
  .map_err(|e| format!("Failed to serialize state: {}", e))?;
57
45
  // Expose state both as bare globals (via Object.assign) and as a
58
46
  // persistent `$` global carrying the runtime `unsafe` helper, so a
59
47
  // `@[$.unsafe(expr)]` binding stamps its trusted string raw at build
60
- // time — mirroring the runtime, where `$.unsafe` marks raw HTML.
48
+ // time — mirroring the runtime, where `$.unsafe` wraps it in RawHtml.
61
49
  ctx.eval::<(), _>(format!(
62
- "const $ = {}; Object.assign(globalThis, $); $.unsafe = (s) => s; globalThis.$ = $",
50
+ "const $ = {}; Object.assign(globalThis, $); \
51
+ globalThis.RawHtml = class {{ constructor(html) {{ this.html = html == null ? '' : String(html) }} }}; \
52
+ $.unsafe = (html) => new RawHtml(html); globalThis.$ = $",
63
53
  state_str
64
54
  ))
65
55
  .map_err(|e| format!("Failed to set up state in QuickJS: {:?}", e))?;
66
56
  Ok(())
67
57
  })?;
68
58
 
69
- let constant_texts = constants
70
- .iter()
71
- .map(|(key, value)| (key.clone(), primitive_to_text(value)))
72
- .collect();
73
-
74
59
  Ok(Self {
75
60
  state,
76
61
  binding_regex: Regex::new(r"@\[((?:[^\[\]]|\[[^\]]*\])+)\]").unwrap(),
77
62
  _runtime: runtime,
78
63
  context,
79
64
  components_as_is,
80
- constant_texts,
81
65
  selective: false,
82
66
  })
83
67
  }
@@ -107,12 +91,40 @@ impl<'a> ValueStamper<'a> {
107
91
  // Then, render conditionals (resolve if/else branches)
108
92
  let html = self.render_conditionals(html)?;
109
93
 
94
+ // Stamp bindings only in regions that are NOT in skip_regions
95
+ let (mut result, unresolved) = self.stamp_bindings_tracked(&html, &self.skip_regions(&html));
96
+
97
+ // Clean up attributes that still contain unresolved markers. Selective
98
+ // mode keeps them: an unresolved marker there is runtime-owned state
99
+ // (the outlet's `src="@[page.src]"`), not an absent iteration property.
100
+ if !self.selective {
101
+ result = self.cleanup_unresolved_attributes(result, &unresolved);
102
+ }
103
+
104
+ // Restore vibe-dehydrate content from placeholders
105
+ for (i, content) in dehydrate_placeholders.iter().enumerate() {
106
+ let placeholder = format!("<!--VIBE_DEHYDRATE_PLACEHOLDER_{}-->", i);
107
+ result = result.replace(&placeholder, content);
108
+ }
109
+
110
+ // Finally, clean up boolean-like attributes with falsy values
111
+ // When components_as_is is true, skip cleanup inside component elements.
112
+ // Regions are measured again on the stamped HTML: stamping changes
113
+ // lengths, so the pre-stamp offsets point at the wrong attributes.
114
+ let skip_regions = self.skip_regions(&result);
115
+ Ok(self.cleanup_boolean_attributes(result, &skip_regions))
116
+ }
117
+
118
+ /// Byte ranges of the regions stamping and cleanup leave untouched:
119
+ /// non-reactive elements, vibe-dehydrate templates and, when
120
+ /// components_as_is is true, component elements.
121
+ fn skip_regions(&self, html: &str) -> Vec<(usize, usize)> {
110
122
  // Find all NON_REACTIVE_ELEMENT regions to skip
111
123
  let mut skip_regions: Vec<(usize, usize)> = Vec::new();
112
124
 
113
125
  for element_type in NON_REACTIVE_ELEMENTS {
114
126
  let regex = Regex::new(&format!(r"(?s)<{}[^>]*>.*?</{}>", element_type, element_type)).unwrap();
115
- for mat in regex.find_iter(&html) {
127
+ for mat in regex.find_iter(html) {
116
128
  skip_regions.push((mat.start(), mat.end()));
117
129
  }
118
130
  }
@@ -120,7 +132,7 @@ impl<'a> ValueStamper<'a> {
120
132
  // Skip elements with vibe-dehydrate attribute (content should not be processed)
121
133
  // This is a secondary protection in case placeholders aren't used
122
134
  let dehydrate_regex = Regex::new(&format!(r"(?s)<template[^>]*\s{}[^>]*>.*?</template>", DEHYDRATE_ATTR)).unwrap();
123
- for mat in dehydrate_regex.find_iter(&html) {
135
+ for mat in dehydrate_regex.find_iter(html) {
124
136
  skip_regions.push((mat.start(), mat.end()));
125
137
  }
126
138
 
@@ -129,55 +141,40 @@ impl<'a> ValueStamper<'a> {
129
141
  if self.components_as_is {
130
142
  // Match <component ...>...</component>
131
143
  let component_regex = Regex::new(r"(?s)<component[^>]*>.*?</component>").unwrap();
132
- for mat in component_regex.find_iter(&html) {
144
+ for mat in component_regex.find_iter(html) {
133
145
  skip_regions.push((mat.start(), mat.end()));
134
146
  }
135
147
 
136
148
  // Match <div class="component" ...>...</div>
137
149
  let div_component_regex = Regex::new(r#"(?s)<div\s+class="component"[^>]*>.*?</div>"#).unwrap();
138
- for mat in div_component_regex.find_iter(&html) {
150
+ for mat in div_component_regex.find_iter(html) {
139
151
  skip_regions.push((mat.start(), mat.end()));
140
152
  }
141
153
  }
142
154
 
143
155
  // Sort and merge overlapping regions
144
156
  skip_regions.sort_by_key(|r| r.0);
145
-
146
- // Stamp bindings only in regions that are NOT in skip_regions
147
- let (mut result, unresolved) = self.stamp_bindings_tracked(&html, &skip_regions);
148
-
149
- // Clean up attributes that still contain unresolved markers. Selective
150
- // mode keeps them: an unresolved marker there is runtime-owned state
151
- // (the outlet's `src="@[page.src]"`), not an absent iteration property.
152
- if !self.selective {
153
- result = self.cleanup_unresolved_attributes(result, &unresolved);
154
- }
155
-
156
- // Restore vibe-dehydrate content from placeholders
157
- for (i, content) in dehydrate_placeholders.iter().enumerate() {
158
- let placeholder = format!("<!--VIBE_DEHYDRATE_PLACEHOLDER_{}-->", i);
159
- result = result.replace(&placeholder, content);
160
- }
161
-
162
- // Finally, clean up boolean-like attributes with falsy values
163
- // When components_as_is is true, skip cleanup inside component elements
164
- Ok(self.cleanup_boolean_attributes(result, &skip_regions))
157
+ skip_regions
165
158
  }
166
159
 
167
160
  /// Evaluate a JavaScript expression with the current state
168
161
  fn eval_expression(&self, expr: &str) -> Option<String> {
169
- self.eval_with_state(expr, self.state)
162
+ self.eval_with_state(expr, &self.state)
170
163
  }
171
164
 
172
165
  /// Evaluate a JavaScript expression with custom state (for nested iterations)
166
+ /// into the HTML it stamps: escaped text, or the raw markup of `$.unsafe`.
173
167
  fn eval_with_state(&self, expr: &str, _state: &Value) -> Option<String> {
174
168
  // State is already set up in the context during new(), just evaluate the expression
175
169
  self.context.with(|ctx| -> Option<String> {
176
- // Evaluate expression and convert to string
177
170
  let result: rquickjs::Value = ctx.eval(expr).ok()?;
171
+ let raw_html: rquickjs::Value = ctx.globals().get("RawHtml").ok()?;
172
+ if let Some(raw) = result.as_object().filter(|o| o.is_instance_of(&raw_html)) {
173
+ return raw.get("html").ok();
174
+ }
178
175
 
179
176
  // Convert result to string
180
- if result.is_string() {
177
+ let text = if result.is_string() {
181
178
  result.as_string().and_then(|s| s.to_string().ok())
182
179
  } else if result.is_number() {
183
180
  result.as_number().map(|n| n.to_string())
@@ -191,7 +188,8 @@ impl<'a> ValueStamper<'a> {
191
188
  // For objects/arrays, convert to JSON
192
189
  ctx.json_stringify(result).ok()
193
190
  .and_then(|v| v.and_then(|s| s.to_string().ok()))
194
- }
191
+ };
192
+ text.map(|text| escape_html(&text))
195
193
  })
196
194
  }
197
195
 
@@ -301,7 +299,7 @@ impl<'a> ValueStamper<'a> {
301
299
 
302
300
  context.with(|ctx| -> Option<Vec<Value>> {
303
301
  // Set state properties in global scope
304
- let state_str = serde_json::to_string(self.state).ok()?;
302
+ let state_str = serde_json::to_string(&self.state).ok()?;
305
303
  ctx.eval::<(), _>(format!("const $ = {}; Object.assign(globalThis, $)", state_str)).ok()?;
306
304
 
307
305
  // Evaluate path to get array
@@ -683,14 +681,10 @@ impl<'a> ValueStamper<'a> {
683
681
  result.push_str(binding_match.as_str());
684
682
  } else {
685
683
  let caps = self.binding_regex.captures(binding_match.as_str()).unwrap();
686
- let expr = &caps[1];
687
- // A bare reference to a proven-constant global key (`@[version]`)
688
- // stamps its build-time value. Any compound expression falls
689
- // through to normal evaluation, where dynamic globals stay absent
690
- // and the binding is left live for the runtime.
691
- if let Some(text) = self.constant_texts.get(expr.trim()) {
692
- result.push_str(text);
693
- } else if let Some(stamped) = self.eval_expression(expr) {
684
+ // The HTML is serialized, so `=>` in a text binding reads `=&gt;`
685
+ // and `"` in an attribute binding reads `&quot;` — decode back to
686
+ // the JS the runtime evaluates.
687
+ if let Some(stamped) = self.eval_expression(&decode_entities(&caps[1])) {
694
688
  result.push_str(&stamped);
695
689
  } else {
696
690
  unresolved.push(result.len());
@@ -791,6 +785,10 @@ impl<'a> ValueStamper<'a> {
791
785
  }
792
786
  }
793
787
 
788
+ fn escape_html(text: &str) -> String {
789
+ text.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
790
+ }
791
+
794
792
  #[cfg(test)]
795
793
  mod tests {
796
794
  use super::*;
@@ -888,6 +886,16 @@ mod tests {
888
886
  assert_eq!(result, "<p><em>x</em></p>");
889
887
  }
890
888
 
889
+ #[test]
890
+ fn stamp_escapes_quotes_and_angle_brackets() {
891
+ let state = json!({ "snippet": r#"<component src="/card.html"></component> & more"# });
892
+ let stamper = ValueStamper::new(&state, false).unwrap();
893
+ let html = String::from(r#"<copy-button data-value="@[snippet]">@[snippet]</copy-button>"#);
894
+ let result = stamper.stamp_html(html).unwrap();
895
+ let escaped = "&lt;component src=&quot;/card.html&quot;&gt;&lt;/component&gt; &amp; more";
896
+ assert_eq!(result, format!(r#"<copy-button data-value="{escaped}">{escaped}</copy-button>"#));
897
+ }
898
+
891
899
  #[test]
892
900
  fn stamp_attribute_binding() {
893
901
  let state = json!({ "firstName": "John" });
@@ -1051,5 +1059,41 @@ mod tests {
1051
1059
  let result = stamper.stamp_html("<x>@[version + suffix]</x>".to_string()).unwrap();
1052
1060
  assert!(!result.contains("0.1.5"), "constant leaked into compound: {result}");
1053
1061
  }
1062
+
1063
+ #[test]
1064
+ fn compound_expression_over_constants_is_stamped_from_serialized_html() {
1065
+ // An inlined component's `@[name.split…]` with `name="@[team]"` arrives
1066
+ // as `@[(team).split…]`, its `=>` serialized to `=&gt;`.
1067
+ let state = json!({});
1068
+ let consts = constants(&[("team", json!("Ada Lovelace"))]);
1069
+ let stamper = ValueStamper::with_constants(&state, false, &consts).unwrap();
1070
+ let result = stamper
1071
+ .stamp_html("<avatar-circle>@[(team).split(' ').map(part =&gt; part[0]).join('')]</avatar-circle>".to_string())
1072
+ .unwrap();
1073
+ assert_eq!(result, "<avatar-circle>AL</avatar-circle>");
1074
+ }
1075
+
1076
+ #[test]
1077
+ fn cleanup_measures_skip_regions_after_a_binding_shrinks() {
1078
+ // The avatar's binding stamps 52 bytes down to `AL`, pulling the hint
1079
+ // box back into where the `<pre>` sat before stamping — cleanup must
1080
+ // still see its attributes as outside the `<pre>`.
1081
+ let state = json!({ "gone": false, "secondary": "" });
1082
+ let consts = constants(&[("team", json!("Ada Lovelace"))]);
1083
+ let stamper = ValueStamper::with_constants(&state, false, &consts).unwrap();
1084
+ let result = stamper
1085
+ .stamp_html(
1086
+ "<avatar-circle>@[(team).split(' ').map(part =&gt; part[0]).join('')]</avatar-circle>\
1087
+ <pre>npm install @ape-egg/vibe --save-dev</pre>\
1088
+ <hint-box gone=\"@[gone]\"><button secondary=\"@[secondary]\">Go</button></hint-box>"
1089
+ .to_string(),
1090
+ )
1091
+ .unwrap();
1092
+ assert_eq!(
1093
+ result,
1094
+ "<avatar-circle>AL</avatar-circle><pre>npm install @ape-egg/vibe --save-dev</pre>\
1095
+ <hint-box><button secondary>Go</button></hint-box>"
1096
+ );
1097
+ }
1054
1098
  }
1055
1099
 
@@ -471,6 +471,10 @@ pub fn watch(config: Config, verbose: bool, launcher: Option<u32>) -> std::resul
471
471
  }
472
472
  }
473
473
 
474
+ if let Err(e) = compiler.remove_stale_output() {
475
+ eprintln!("\n{}: Removing stale output failed: {}", "Warning".yellow(), e);
476
+ }
477
+
474
478
  // Show success headline
475
479
  println!("\n{}", "Initial compilation complete!".green().bold());
476
480
  println!();
@@ -525,8 +529,8 @@ pub fn watch(config: Config, verbose: bool, launcher: Option<u32>) -> std::resul
525
529
  if let Some(nm_time) = stats.node_modules_time_ms {
526
530
  if stats.node_modules_copied_as_is {
527
531
  println!("* copied node_modules in {:.0}ms", nm_time);
528
- } else if let Some(ref pkg_manager) = stats.package_manager {
529
- println!("* {} install in {:.0}ms", pkg_manager, nm_time);
532
+ } else {
533
+ println!("* copied dependencies in {:.0}ms", nm_time);
530
534
  }
531
535
  }
532
536
 
@@ -879,7 +883,6 @@ pub fn watch(config: Config, verbose: bool, launcher: Option<u32>) -> std::resul
879
883
  components_time_ms: 0.0,
880
884
  validation_time_ms: None,
881
885
  node_modules_time_ms: None,
882
- package_manager: None,
883
886
  node_modules_copied_as_is: false,
884
887
  components_as_is: config.components_as_is,
885
888
  };
package/src/config.rs CHANGED
@@ -38,6 +38,8 @@ pub struct VibeCompilerConfig {
38
38
  pub skip_files: Vec<String>,
39
39
  #[serde(default)]
40
40
  pub node_modules_as_is: bool,
41
+ #[serde(default = "default_modules")]
42
+ pub modules: String,
41
43
  #[serde(default)]
42
44
  pub components_as_is: bool,
43
45
  #[serde(default)]
@@ -73,6 +75,7 @@ fn default_output() -> String { "./compiled".to_string() }
73
75
  fn default_components() -> String { "components".to_string() }
74
76
  fn default_pages() -> String { "pages".to_string() }
75
77
  fn default_assets() -> String { "assets".to_string() }
78
+ fn default_modules() -> String { "node_modules".to_string() }
76
79
 
77
80
  /// Get list of standard HTML5 elements + "component"
78
81
  /// Component names matching these are reserved and not allowed
@@ -118,6 +121,7 @@ impl Default for VibeCompilerConfig {
118
121
  reserved_elements: vec![],
119
122
  skip_files: vec![],
120
123
  node_modules_as_is: false,
124
+ modules: default_modules(),
121
125
  components_as_is: false,
122
126
  runtime_as_is: false,
123
127
  iterations_as_is: false,
@@ -145,6 +149,7 @@ pub struct Config {
145
149
  pub reserved_elements: Vec<String>,
146
150
  pub skip_files: Vec<String>,
147
151
  pub node_modules_as_is: bool,
152
+ pub modules: String,
148
153
  pub components_as_is: bool,
149
154
  pub runtime_as_is: bool,
150
155
  pub iterations_as_is: bool,
@@ -208,6 +213,7 @@ impl Config {
208
213
  reserved_elements,
209
214
  skip_files,
210
215
  node_modules_as_is: config.node_modules_as_is,
216
+ modules: config.modules,
211
217
  components_as_is: config.components_as_is,
212
218
  runtime_as_is: config.runtime_as_is,
213
219
  iterations_as_is: config.iterations_as_is,
@@ -219,6 +225,10 @@ impl Config {
219
225
  }
220
226
  }
221
227
 
228
+ pub fn modules_path(&self) -> PathBuf {
229
+ self.output.join(&self.modules)
230
+ }
231
+
222
232
  pub fn components_path(&self) -> PathBuf {
223
233
  self.source.join(&self.components)
224
234
  }
package/src/main.rs CHANGED
@@ -242,6 +242,7 @@ fn main() {
242
242
  println!(" {}: {}", format!("{:<16}", "elementsAsIs").cyan(), format_bool_with_flag(config.elements_as_is, overrides.elements_as_is, original_elements_as_is));
243
243
  println!(" {}: {}", format!("{:<16}", "iterationsAsIs").cyan(), format_bool_with_flag(config.iterations_as_is, overrides.iterations_as_is, original_iterations_as_is));
244
244
  println!(" {}: {}", format!("{:<16}", "minify").cyan(), format_bool_with_flag(config.minify, overrides.minify, original_minify));
245
+ println!(" {}: {}", format!("{:<16}", "modules").cyan(), format_value_no_flag(&config.modules));
245
246
  println!(" {}: {}", format!("{:<16}", "nodeModulesAsIs").cyan(), format_bool_with_flag(config.node_modules_as_is, overrides.node_modules_as_is, original_node_modules_as_is));
246
247
  println!(" {}: {}", format!("{:<16}", "output").cyan(), format_value_no_flag(&config._output_str));
247
248
  println!(" {}: {}", format!("{:<16}", "pages").cyan(), format_value_no_flag(&config.pages));
@@ -291,6 +292,10 @@ fn main() {
291
292
  }
292
293
  }
293
294
 
295
+ if let Err(e) = compiler.remove_stale_output() {
296
+ eprintln!("\n{}: Removing stale output failed: {}", "Warning".yellow(), e);
297
+ }
298
+
294
299
  // Show success headline
295
300
  println!("\n{}", format!("Compilation successful! (v{}) ✅", env!("CARGO_PKG_VERSION")).green().bold());
296
301
  println!();
@@ -345,8 +350,8 @@ fn main() {
345
350
  if let Some(nm_time) = stats.node_modules_time_ms {
346
351
  if stats.node_modules_copied_as_is {
347
352
  println!("* copied node_modules in {:.0}ms", nm_time);
348
- } else if let Some(ref pkg_manager) = stats.package_manager {
349
- println!("* {} install in {:.0}ms", pkg_manager, nm_time);
353
+ } else {
354
+ println!("* copied dependencies in {:.0}ms", nm_time);
350
355
  }
351
356
  }
352
357
 
@@ -681,7 +681,7 @@ fn escape_attr_value(value: &str) -> String {
681
681
  .replace('\'', "&#39;")
682
682
  }
683
683
 
684
- fn decode_entities(value: &str) -> String {
684
+ pub(crate) fn decode_entities(value: &str) -> String {
685
685
  value
686
686
  .replace("&quot;", "\"")
687
687
  .replace("&#34;", "\"")