@ape-egg/vibe-compiler 4.3.24 → 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.24",
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.23"
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;
@@ -11,7 +10,7 @@ use rayon::prelude::*;
11
10
  use serde_json::{Map, Value};
12
11
 
13
12
  use crate::config::Config;
14
- use crate::parser::HtmlParser;
13
+ use crate::parser::{HtmlParser, component_key};
15
14
 
16
15
  /// Map an output-relative HTML path to the URL path the runtime resolves a
17
16
  /// manifest from. Strips the `root` dir (served at /) and collapses dynamic
@@ -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.
@@ -239,13 +260,13 @@ fn extract_event_handlers(html: &str) -> Vec<String> {
239
260
  /// the inliner's `src=` regex won't match the renamed attribute, so it stops
240
261
  /// re-expanding. `process_html_with_cache` restores the attribute to plain
241
262
  /// `src=` at the end of compilation so the runtime fetches it normally.
242
- fn escape_recursive_src(content: &str, component_src: &str) -> String {
243
- let pattern = format!(
244
- r#"(<(?:component|div)\s+[^>]*\b)src="{}""#,
245
- regex::escape(component_src),
246
- );
247
- let re = Regex::new(&pattern).unwrap();
248
- re.replace_all(content, format!(r#"$1{}="{}""#, RECURSIVE_SRC_ATTR, component_src))
263
+ fn escape_recursive_src(content: &str, key: &str) -> String {
264
+ Regex::new(r#"(<(?:component|div)\s(?:[^>]*\s)?)src="([^"]+)""#)
265
+ .unwrap()
266
+ .replace_all(content, |cap: &regex::Captures| match component_key(&cap[2]) == key {
267
+ true => format!(r#"{}{}="{}""#, &cap[1], RECURSIVE_SRC_ATTR, &cap[2]),
268
+ false => cap[0].to_string(),
269
+ })
249
270
  .into_owned()
250
271
  }
251
272
 
@@ -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
  })?;
@@ -1875,19 +1870,19 @@ impl Compiler {
1875
1870
  }
1876
1871
 
1877
1872
  fn collect_component_children(&mut self, component_src: &str, relationships: &mut Vec<(String, Vec<String>)>, visited: &mut HashSet<String>) {
1878
- if visited.contains(component_src) {
1873
+ let key = component_key(component_src);
1874
+ if !visited.insert(key.clone()) {
1879
1875
  return;
1880
1876
  }
1881
- visited.insert(component_src.to_string());
1882
1877
 
1883
1878
  // Read component content
1884
- let content = if component_src.starts_with("http://") || component_src.starts_with("https://") {
1885
- match self.fetch_external_component_raw(component_src) {
1879
+ let content = if key.starts_with("http://") || key.starts_with("https://") {
1880
+ match self.fetch_external_component_raw(&key) {
1886
1881
  Ok(c) => c,
1887
1882
  Err(_) => return,
1888
1883
  }
1889
1884
  } else {
1890
- match self.resolve_component_path(component_src) {
1885
+ match self.resolve_component_path(&key) {
1891
1886
  Ok(path) => match fs::read_to_string(&path) {
1892
1887
  Ok(c) => c,
1893
1888
  Err(_) => return,
@@ -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
  })?;
@@ -2050,17 +2043,7 @@ impl Compiler {
2050
2043
  parser: &HtmlParser,
2051
2044
  visiting: &mut std::collections::HashSet<String>,
2052
2045
  ) -> Option<String> {
2053
- // Normalize path: ensure it starts with / (unless it's a URL)
2054
- let normalized_src = if component_src.starts_with("http://") || component_src.starts_with("https://") {
2055
- component_src.to_string()
2056
- } else {
2057
- let without_prefix = component_src.trim_start_matches("./");
2058
- if without_prefix.starts_with('/') {
2059
- without_prefix.to_string()
2060
- } else {
2061
- format!("/{}", without_prefix)
2062
- }
2063
- };
2046
+ let normalized_src = component_key(component_src);
2064
2047
 
2065
2048
  // Return cached if already fetched and fully resolved
2066
2049
  if let Some(cached) = self.component_cache.get(&normalized_src) {
@@ -2504,120 +2487,96 @@ impl Compiler {
2504
2487
  Ok(())
2505
2488
  }
2506
2489
 
2507
- /// Copy node_modules as-is to output
2508
- fn copy_node_modules_as_is(&self) -> Result<(), CompileError> {
2509
- let src = self.config.working_dir.join("node_modules");
2510
- let dest = self.config.output.join("node_modules");
2511
-
2512
- // Remove existing node_modules in output if it exists
2513
- if dest.exists() {
2514
- fs::remove_dir_all(&dest).map_err(|e| CompileError::WriteError {
2515
- path: dest.display().to_string(),
2516
- source: e,
2517
- })?;
2518
- }
2519
-
2520
- // Copy recursively
2521
- copy_dir_recursive(&src, &dest)?;
2522
-
2523
- Ok(())
2524
- }
2525
-
2526
- /// Install production dependencies directly into output directory
2527
- fn copy_production_node_modules(&self) -> Result<(), CompileError> {
2528
- let pkg_manager = detect_package_manager(&self.config.working_dir)?;
2529
-
2530
- if self.verbose {
2531
- println!("\n Detected package manager: {}", pkg_manager);
2532
- println!(" Installing production dependencies to output...");
2533
- }
2534
-
2535
- // 1. Copy package.json to output directory
2536
- let package_json_src = self.config.working_dir.join("package.json");
2537
- let package_json_dest = self.config.output.join("package.json");
2538
-
2539
- fs::copy(&package_json_src, &package_json_dest).map_err(|e| CompileError::WriteError {
2540
- path: package_json_dest.display().to_string(),
2541
- source: e,
2542
- })?;
2543
-
2544
- // 2. Copy lockfile if it exists (needed for reproducible installs)
2545
- let lockfile_name = match pkg_manager.as_str() {
2546
- "bun" if self.config.working_dir.join("bun.lock").exists() => "bun.lock",
2547
- "bun" => "bun.lockb",
2548
- "pnpm" => "pnpm-lock.yaml",
2549
- "yarn" => "yarn.lock",
2550
- "npm" => "package-lock.json",
2551
- _ => "",
2552
- };
2553
-
2554
- if !lockfile_name.is_empty() {
2555
- let lockfile_src = self.config.working_dir.join(lockfile_name);
2556
- let lockfile_dest = self.config.output.join(lockfile_name);
2557
- if lockfile_src.exists() {
2558
- 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;
2559
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);
2560
2528
  }
2561
-
2562
- // 3. Install production dependencies directly in output directory
2563
- run_command(&pkg_manager, &["install", "--production"], &self.config.output)?;
2564
-
2565
- // 4. Cleanup: remove package.json and lockfile from output
2566
- fs::remove_file(&package_json_dest).ok();
2567
- if !lockfile_name.is_empty() {
2568
- let lockfile_dest = self.config.output.join(lockfile_name);
2569
- fs::remove_file(&lockfile_dest).ok();
2570
- }
2571
-
2572
- if self.verbose {
2573
- println!(" Installed production dependencies to output");
2574
- }
2575
-
2576
2529
  Ok(())
2577
2530
  }
2578
2531
  }
2579
2532
 
2580
- /// Detect package manager from lockfiles
2581
- fn detect_package_manager(dir: &Path) -> Result<String, CompileError> {
2582
- if dir.join("bun.lockb").exists() || dir.join("bun.lock").exists() {
2583
- return Ok("bun".to_string());
2584
- }
2585
- if dir.join("pnpm-lock.yaml").exists() {
2586
- return Ok("pnpm".to_string());
2587
- }
2588
- if dir.join("yarn.lock").exists() {
2589
- return Ok("yarn".to_string());
2590
- }
2591
- if dir.join("package-lock.json").exists() {
2592
- 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
+ }
2593
2552
  }
2594
-
2595
- Err(CompileError::PackageManagerNotFound)
2553
+ Ok(removed)
2596
2554
  }
2597
2555
 
2598
- /// Run a package manager command
2599
- fn run_command(pkg_manager: &str, args: &[&str], cwd: &Path) -> Result<(), CompileError> {
2600
- let output = Command::new(pkg_manager)
2601
- .args(args)
2602
- .current_dir(cwd)
2603
- .output()
2604
- .map_err(|e| CompileError::CommandError(format!("{} not found: {}", pkg_manager, e)))?;
2605
-
2606
- if !output.status.success() {
2607
- let stderr = String::from_utf8_lossy(&output.stderr);
2608
- return Err(CompileError::CommandError(format!(
2609
- "{} {} failed: {}",
2610
- pkg_manager,
2611
- args.join(" "),
2612
- stderr
2613
- )));
2614
- }
2615
-
2616
- 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())
2617
2569
  }
2618
2570
 
2619
2571
  /// Recursively copy a directory
2620
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
+ }
2621
2580
  if !dest.exists() {
2622
2581
  fs::create_dir_all(dest).map_err(|_| CompileError::CreateDirError(dest.display().to_string()))?;
2623
2582
  }
@@ -2645,7 +2604,7 @@ fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), CompileError> {
2645
2604
  }
2646
2605
  copy_dir_recursive(&path, &dest_path)?;
2647
2606
  } else {
2648
- fs::copy(&path, &dest_path).map_err(|e| CompileError::WriteError {
2607
+ copy_in_place(&path, &dest_path).map_err(|e| CompileError::WriteError {
2649
2608
  path: dest_path.display().to_string(),
2650
2609
  source: e,
2651
2610
  })?;
@@ -2744,6 +2703,19 @@ fn count_newlines(bytes: &[u8]) -> usize {
2744
2703
  mod tests {
2745
2704
  use super::*;
2746
2705
 
2706
+ #[test]
2707
+ fn escape_recursive_src_matches_a_cycle_written_without_extension() {
2708
+ let out = escape_recursive_src(
2709
+ r#"<component src="/tree"></component><component src="/leaf.html"></component>"#,
2710
+ "/tree.html",
2711
+ );
2712
+ assert_eq!(
2713
+ out,
2714
+ format!(r#"<component {RECURSIVE_SRC_ATTR}="/tree"></component><component src="/leaf.html"></component>"#),
2715
+ );
2716
+ assert_eq!(escape_recursive_src(&out, "/tree.html"), out, "an escaped src was escaped again");
2717
+ }
2718
+
2747
2719
  #[test]
2748
2720
  fn minify_preserves_script_newlines_so_line_comments_dont_swallow_code() {
2749
2721
  // A `//` line comment inside a component script relies on its trailing
@@ -2787,17 +2759,16 @@ mod tests {
2787
2759
  }
2788
2760
 
2789
2761
  #[test]
2790
- fn atomic_write_replaces_content_without_leaving_tmp_files() {
2791
- 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");
2792
2764
  let _ = fs::remove_dir_all(&dir);
2793
2765
  fs::create_dir_all(&dir).unwrap();
2794
2766
  let target = dir.join("page.html");
2795
2767
 
2796
- atomic_write(&target, "first").unwrap();
2768
+ write_in_place(&target, "first").unwrap();
2797
2769
  assert_eq!(fs::read_to_string(&target).unwrap(), "first");
2798
2770
 
2799
- // Overwriting an existing file goes through the same tmp+rename path.
2800
- atomic_write(&target, "second, longer content").unwrap();
2771
+ write_in_place(&target, "second, longer content").unwrap();
2801
2772
  assert_eq!(fs::read_to_string(&target).unwrap(), "second, longer content");
2802
2773
 
2803
2774
  let leftovers: Vec<String> = fs::read_dir(&dir)
@@ -2809,6 +2780,30 @@ mod tests {
2809
2780
  assert!(leftovers.is_empty(), "temp artifacts left behind: {leftovers:?}");
2810
2781
  }
2811
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
+
2812
2807
  // A minimal on-disk project: source with one page carrying a distinctive
2813
2808
  // binding, empty components dir, output dir sibling. Returns (config, page
2814
2809
  // source path, compiled page output path, manifest path).
@@ -2878,6 +2873,7 @@ mod tests {
2878
2873
  reserved_elements: Vec::new(),
2879
2874
  skip_files: Vec::new(),
2880
2875
  node_modules_as_is: false,
2876
+ modules: "node_modules".to_string(),
2881
2877
  components_as_is: true,
2882
2878
  runtime_as_is: false,
2883
2879
  iterations_as_is: false,
@@ -2967,6 +2963,7 @@ mod tests {
2967
2963
  reserved_elements: Vec::new(),
2968
2964
  skip_files: Vec::new(),
2969
2965
  node_modules_as_is: false,
2966
+ modules: "node_modules".to_string(),
2970
2967
  components_as_is: false,
2971
2968
  runtime_as_is: false,
2972
2969
  iterations_as_is: false,
@@ -3080,6 +3077,7 @@ mod tests {
3080
3077
  reserved_elements: Vec::new(),
3081
3078
  skip_files: Vec::new(),
3082
3079
  node_modules_as_is: false,
3080
+ modules: "node_modules".to_string(),
3083
3081
  components_as_is: false,
3084
3082
  runtime_as_is: false,
3085
3083
  iterations_as_is: false,
@@ -3152,6 +3150,7 @@ mod tests {
3152
3150
  reserved_elements: Vec::new(),
3153
3151
  skip_files: Vec::new(),
3154
3152
  node_modules_as_is: false,
3153
+ modules: "node_modules".to_string(),
3155
3154
  components_as_is: false,
3156
3155
  runtime_as_is: false,
3157
3156
  iterations_as_is: false,
@@ -622,10 +622,10 @@ mod tests {
622
622
  #[test]
623
623
  fn harvests_and_derives_shell_imports() {
624
624
  assert_eq!(
625
- harvest_vibe_import("import vibe from '/nodemodules/@ape-egg/vibe/index.js';"),
626
- Some("/nodemodules/@ape-egg/vibe/index.js".to_string())
625
+ harvest_vibe_import("import vibe from '/node_modules/@ape-egg/vibe/index.js';"),
626
+ Some("/node_modules/@ape-egg/vibe/index.js".to_string())
627
627
  );
628
- assert_eq!(spa_import_for("/nodemodules/@ape-egg/vibe/index.js"), "/nodemodules/@ape-egg/vibe/spa.js");
628
+ assert_eq!(spa_import_for("/node_modules/@ape-egg/vibe/index.js"), "/node_modules/@ape-egg/vibe/spa.js");
629
629
  assert_eq!(spa_import_for("@ape-egg/vibe"), "@ape-egg/vibe/spa");
630
630
  }
631
631
 
@@ -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
 
@@ -8,6 +8,7 @@ use regex::Regex;
8
8
 
9
9
  use super::{Compiler, compile::should_skip_path, shutdown};
10
10
  use crate::config::Config;
11
+ use crate::parser::component_key;
11
12
  use crate::constants::{HYPERSPEED_DIR, MANIFEST_SUFFIX};
12
13
 
13
14
  /// Tracks which pages and components depend on which components
@@ -159,8 +160,7 @@ pub fn extract_component_dependencies(html: &str, components_dir: &Path) -> Hash
159
160
  let component_tag_re = Regex::new(r#"<component\s+[^>]*src=["']([^"']+)["']"#).unwrap();
160
161
  for cap in component_tag_re.captures_iter(html) {
161
162
  if let Some(src) = cap.get(1) {
162
- let src_path = src.as_str().trim_start_matches('/');
163
- deps.insert(PathBuf::from(src_path));
163
+ deps.insert(PathBuf::from(component_key(src.as_str()).trim_start_matches('/')));
164
164
  }
165
165
  }
166
166
 
@@ -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
 
@@ -7,6 +7,22 @@ use super::element::{Element, ElementCache};
7
7
  use crate::inert::Inert;
8
8
  use crate::constants::{RECURSIVE_SRC_ATTR, MODULE_SCRIPT_TYPE};
9
9
 
10
+ /// The key a `<component src>` is fetched and cached under: rooted at `/` unless
11
+ /// it is a URL, with `.html` appended when its last path segment has no
12
+ /// extension, so `/vibe/counter` and `/vibe/counter.html` are one component.
13
+ /// Mirrors `fetchComponentTemplate` in the vibe runtime.
14
+ pub fn component_key(src: &str) -> String {
15
+ let rooted = if src.starts_with("http://") || src.starts_with("https://") {
16
+ src.to_string()
17
+ } else {
18
+ format!("/{}", src.trim_start_matches("./").trim_start_matches('/'))
19
+ };
20
+ regex::Regex::new(r"^((?:[^?#]*/)?[^/?#.]+)([?#].*)?$")
21
+ .unwrap()
22
+ .replace(&rooted, "$1.html$2")
23
+ .into_owned()
24
+ }
25
+
10
26
  #[derive(Error, Debug)]
11
27
  pub enum ParseError {
12
28
  #[error("Failed to read file {path}: {source}")]
@@ -449,18 +465,7 @@ impl HtmlParser {
449
465
 
450
466
  let mut applied = false;
451
467
  for (start, end, src, props, slot_content) in sorted {
452
- let normalized_src = if src.starts_with("http://") || src.starts_with("https://") {
453
- src.to_string()
454
- } else {
455
- let without_prefix = src.trim_start_matches("./");
456
- if without_prefix.starts_with('/') {
457
- without_prefix.to_string()
458
- } else {
459
- format!("/{}", without_prefix)
460
- }
461
- };
462
-
463
- if let Some(template) = external_cache.get(&normalized_src) {
468
+ if let Some(template) = external_cache.get(&component_key(&src)) {
464
469
  // Iter-prop components stay as runtime `<component src>` tags so the
465
470
  // runtime renders them via its __vibe.iterProps path — inlining
466
471
  // breaks the enclosing loop's scope (see is_iter_prop_root).
@@ -676,7 +681,7 @@ fn escape_attr_value(value: &str) -> String {
676
681
  .replace('\'', "&#39;")
677
682
  }
678
683
 
679
- fn decode_entities(value: &str) -> String {
684
+ pub(crate) fn decode_entities(value: &str) -> String {
680
685
  value
681
686
  .replace("&quot;", "\"")
682
687
  .replace("&#34;", "\"")
@@ -891,6 +896,36 @@ fn transform_custom_tags(content: &str, reserved_elements: &[String]) -> String
891
896
  mod tests {
892
897
  use super::*;
893
898
 
899
+ #[test]
900
+ fn component_key_appends_html_to_an_extensionless_src() {
901
+ let cases = [
902
+ ("/vibe/counter", "/vibe/counter.html"),
903
+ ("vibe/counter", "/vibe/counter.html"),
904
+ ("./vibe/counter", "/vibe/counter.html"),
905
+ ("/vibe/counter.html", "/vibe/counter.html"),
906
+ ("/vibe/counter/v2.html", "/vibe/counter/v2.html"),
907
+ ("/vibe/counter?v=2", "/vibe/counter.html?v=2"),
908
+ ("https://x.com/vibe/counter#a", "https://x.com/vibe/counter.html#a"),
909
+ ("/v1.2/counter", "/v1.2/counter.html"),
910
+ ("/a/b.html?x=/y", "/a/b.html?x=/y"),
911
+ ("/dir/", "/dir/"),
912
+ ];
913
+ for (src, key) in cases {
914
+ assert_eq!(component_key(src), key, "src {src}");
915
+ }
916
+ }
917
+
918
+ #[test]
919
+ fn inline_component_without_extension_uses_the_html_template() {
920
+ let parser = HtmlParser::new(std::path::PathBuf::from("."));
921
+ let mut cache = HashMap::new();
922
+ cache.insert("/components/Potion.html".to_string(), "<icon potion></icon>".to_string());
923
+
924
+ let out = parser.inline_component_elements("<wrap><component src=\"/components/Potion\"></component></wrap>", &cache);
925
+
926
+ assert_eq!(out, "<wrap><component><icon potion></icon></component></wrap>");
927
+ }
928
+
894
929
  #[test]
895
930
  fn find_matching_close_tolerates_whitespace_in_end_tag() {
896
931
  // Whitespace-controlled markup splits an end tag across lines, e.g.
package/src/parser/mod.rs CHANGED
@@ -1,7 +1,7 @@
1
1
  pub mod html;
2
2
  mod element;
3
3
 
4
- pub use html::HtmlParser;
4
+ pub use html::{HtmlParser, component_key};
5
5
  #[allow(unused_imports)]
6
6
  pub use html::ParseError;
7
7
  #[allow(unused_imports)]