@ape-egg/vibe 2.1.21 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -281,6 +281,8 @@ pub enum CompileError {
281
281
  component_name: String,
282
282
  file_path: String,
283
283
  },
284
+ #[error("SPA mode: {0}")]
285
+ SpaError(String),
284
286
  }
285
287
 
286
288
  pub struct CompileStats {
@@ -695,6 +697,13 @@ impl Compiler {
695
697
  // Process source directory for assets (CSS, JS, images, etc.)
696
698
  self.process_directory_assets_only(&canonical_source, "", &canonical_output, &canonical_source, &mut stats)?;
697
699
 
700
+ // SPA mode: pages tree → fragments + route table + composed shell.
701
+ // Runs after both passes so the shell wins over any root index.html
702
+ // and fragments land beside the mirrored components.
703
+ if self.config.spa {
704
+ stats.files_compiled += self.compile_spa(&parser)?;
705
+ }
706
+
698
707
  let process_time = compile_start.elapsed();
699
708
 
700
709
  // Print verbose output after processing (components first, then HTML, then copied)
@@ -912,6 +921,7 @@ impl Compiler {
912
921
  let iterations_as_is = self.config.iterations_as_is;
913
922
  let components_as_is = self.config.components_as_is;
914
923
  let manifest_root = self.config.root.clone();
924
+ let spa = self.config.spa;
915
925
  // Resolve constant global-state keys once, shared read-only across pages.
916
926
  let global_constants = self.compute_global_constants();
917
927
 
@@ -956,6 +966,7 @@ impl Compiler {
956
966
  &source_root,
957
967
  manifest_root.as_deref(),
958
968
  &global_constants,
969
+ !(spa && relative_path == "index.html"),
959
970
  ) {
960
971
  Ok(()) => true,
961
972
  Err(e) => {
@@ -992,6 +1003,7 @@ impl Compiler {
992
1003
  source_root: &Path,
993
1004
  manifest_root: Option<&str>,
994
1005
  global_constants: &Map<String, Value>,
1006
+ stamp: bool,
995
1007
  ) -> Result<(), String> {
996
1008
  use crate::compiler::manifest_builder::ManifestBuilder;
997
1009
  use crate::compiler::component_tagger::ComponentTagger;
@@ -1047,13 +1059,22 @@ impl Compiler {
1047
1059
  // - Iterations are pre-rendered with initial array data
1048
1060
  // The manifest (written above) preserves BOTH branches so the runtime can
1049
1061
  // restore them and switch between branches reactively.
1050
- let stamper = ValueStamper::with_constants(&state, components_as_is, global_constants)
1051
- .map_err(|e| format!("Failed to create value stamper: {}", e))?;
1052
- let stamped = stamper.stamp_html(html.to_string())
1053
- .map_err(|e| format!("Failed to stamp HTML: {}", e))?;
1054
-
1055
- atomic_write(html_path, &stamped)
1056
- .map_err(|e| format!("Failed to write stamped HTML: {}", e))?;
1062
+ //
1063
+ // The SPA shell opts out (stamp: false): its state is location-dependent
1064
+ // unstampable by design — and it is served at EVERY route path while
1065
+ // its manifest only resolves at /. Stamping would strip the raw
1066
+ // `@[page.src]` outlet binding and leave deep links with nothing to
1067
+ // hydrate from; unstamped, the runtime processes the raw binding at any
1068
+ // URL (vibe-fouc covers the flash).
1069
+ if stamp {
1070
+ let stamper = ValueStamper::with_constants(&state, components_as_is, global_constants)
1071
+ .map_err(|e| format!("Failed to create value stamper: {}", e))?;
1072
+ let stamped = stamper.stamp_html(html.to_string())
1073
+ .map_err(|e| format!("Failed to stamp HTML: {}", e))?;
1074
+
1075
+ atomic_write(html_path, &stamped)
1076
+ .map_err(|e| format!("Failed to write stamped HTML: {}", e))?;
1077
+ }
1057
1078
 
1058
1079
  Ok(())
1059
1080
  }
@@ -1133,6 +1154,7 @@ impl Compiler {
1133
1154
  let iterations_as_is = self.config.iterations_as_is;
1134
1155
  let components_as_is = self.config.components_as_is;
1135
1156
  let manifest_root = self.config.root.clone();
1157
+ let spa = self.config.spa;
1136
1158
  // Resolve which global state keys are constant once, then share across all
1137
1159
  // pages (read-only; `&Map` is Sync so the parallel map can borrow it).
1138
1160
  let global_constants = self.compute_global_constants();
@@ -1162,8 +1184,10 @@ impl Compiler {
1162
1184
  }
1163
1185
  };
1164
1186
 
1165
- // Try to generate manifest for this file (skip on error)
1166
- match Self::generate_file_manifest(html, html_path, &output_dir, relative_path, verbose, iterations_as_is, components_as_is, &source_root, manifest_root.as_deref(), &global_constants) {
1187
+ // Try to generate manifest for this file (skip on error).
1188
+ // The SPA shell keeps its raw bindings (manifest yes, stamp no).
1189
+ let stamp = !(spa && relative_path == "index.html");
1190
+ match Self::generate_file_manifest(html, html_path, &output_dir, relative_path, verbose, iterations_as_is, components_as_is, &source_root, manifest_root.as_deref(), &global_constants, stamp) {
1167
1191
  Ok(()) => (true, None),
1168
1192
  Err(e) => {
1169
1193
  if verbose {
@@ -1229,6 +1253,12 @@ impl Compiler {
1229
1253
  continue;
1230
1254
  }
1231
1255
 
1256
+ // SPA mode: the pages tree becomes fragments + shell via
1257
+ // compile_spa — no mirrored MPA documents in the output.
1258
+ if self.config.spa && self.is_spa_pages_dir(&canonical_path) {
1259
+ continue;
1260
+ }
1261
+
1232
1262
  // Recurse into subdirectory
1233
1263
  let new_relative = if relative_path.is_empty() {
1234
1264
  file_name.to_string()
@@ -1413,6 +1443,214 @@ impl Compiler {
1413
1443
  Ok((internal_count, external_count, component_srcs))
1414
1444
  }
1415
1445
 
1446
+ /// True when a canonicalized directory is the configured pages tree.
1447
+ fn is_spa_pages_dir(&self, canonical_path: &Path) -> bool {
1448
+ self.config
1449
+ .source
1450
+ .join(&self.config.pages)
1451
+ .canonicalize()
1452
+ .map(|pages| pages == *canonical_path)
1453
+ .unwrap_or(false)
1454
+ }
1455
+
1456
+ fn collect_page_files(dir: &Path, skip: &[String], files: &mut Vec<PathBuf>) {
1457
+ let Ok(entries) = fs::read_dir(dir) else { return };
1458
+ for entry in entries.flatten() {
1459
+ let path = entry.path();
1460
+ let name = entry.file_name();
1461
+ let name = name.to_string_lossy();
1462
+ if should_skip_path(&path, &name, skip) {
1463
+ continue;
1464
+ }
1465
+ if path.is_dir() {
1466
+ Self::collect_page_files(&path, skip, files);
1467
+ } else if path.extension().and_then(|e| e.to_str()) == Some("html") {
1468
+ files.push(path);
1469
+ }
1470
+ }
1471
+ }
1472
+
1473
+ /// SPA mode (fetched): transform every page into a fragment under
1474
+ /// output/components/vibe-spa/, then compose the shell at output root.
1475
+ /// Per-page stamped HTML, per-page manifests, and the pages/ output dir
1476
+ /// do not exist in SPA mode — fragments are runtime-parsed on mount; the
1477
+ /// shell flows through the normal manifest pipeline via compiled_html.
1478
+ /// Returns the number of files written (pages + shell) for stats.
1479
+ ///
1480
+ /// Watch mode re-runs this whole pass on any pages-tree change — the
1481
+ /// shell depends on every page's head/title/body attrs and the pass is
1482
+ /// a few milliseconds, so per-page incrementality would buy nothing.
1483
+ pub(crate) fn compile_spa(&mut self, parser: &HtmlParser) -> Result<usize, CompileError> {
1484
+ use crate::compiler::spa;
1485
+
1486
+ let pages_dir = self.config.source.join(&self.config.pages);
1487
+ if !pages_dir.exists() {
1488
+ return Err(CompileError::SpaError(format!(
1489
+ "needs a pages directory at {}",
1490
+ pages_dir.display()
1491
+ )));
1492
+ }
1493
+ let reserved = self.config.components_path().join("vibe-spa");
1494
+ if reserved.exists() {
1495
+ return Err(CompileError::SpaError(format!(
1496
+ "{} is reserved for compiled page fragments — move or rename it",
1497
+ reserved.display()
1498
+ )));
1499
+ }
1500
+
1501
+ let mut page_files = Vec::new();
1502
+ Self::collect_page_files(&pages_dir, &self.config.skip_files, &mut page_files);
1503
+ page_files.sort();
1504
+ if page_files.is_empty() {
1505
+ return Err(CompileError::SpaError(format!(
1506
+ "found no pages under {}",
1507
+ pages_dir.display()
1508
+ )));
1509
+ }
1510
+
1511
+ let mut pages = Vec::new();
1512
+ for path in &page_files {
1513
+ let rel = path
1514
+ .strip_prefix(&pages_dir)
1515
+ .unwrap()
1516
+ .to_string_lossy()
1517
+ .replace('\\', "/");
1518
+ let content = fs::read_to_string(path).map_err(|e| CompileError::ReadError {
1519
+ path: path.display().to_string(),
1520
+ source: e,
1521
+ })?;
1522
+ let processed = parser.process_html_with_cache(
1523
+ &content,
1524
+ self.config.elements_as_is,
1525
+ &self.config.reserved_elements,
1526
+ self.config.components_as_is,
1527
+ &self.config.components,
1528
+ &self.component_cache,
1529
+ );
1530
+ // Build-inlined children get the compiled-document treatment:
1531
+ // tagged wrapper ids + this.→_cN rewrites, matching their
1532
+ // vibe-module scripts (the runtime's mounted-subtree pass claims
1533
+ // ids via closest wrapper). Same protect→tag order as the
1534
+ // manifest pipeline; the extracted state is a page concern and
1535
+ // fragments have none to stamp.
1536
+ let protected = crate::compiler::name_binding_protect::protect(&processed);
1537
+ let tagged =
1538
+ crate::compiler::component_tagger::ComponentTagger::tag_components(
1539
+ &protected,
1540
+ &self.config.source,
1541
+ )
1542
+ .map_err(CompileError::SpaError)?;
1543
+ let page = spa::dissect_page(&rel, &tagged.html).map_err(CompileError::SpaError)?;
1544
+
1545
+ if let Some(api) = page.hygiene_offender {
1546
+ eprintln!(
1547
+ "{}: pages/{} starts side effects ({}) without an $.on('unmount') teardown — under SPA these outlive navigation",
1548
+ "Warning".yellow(),
1549
+ rel,
1550
+ api
1551
+ );
1552
+ }
1553
+
1554
+ let fragment_path = self
1555
+ .config
1556
+ .output
1557
+ .join("components")
1558
+ .join("vibe-spa")
1559
+ .join(&rel);
1560
+ if let Some(parent) = fragment_path.parent() {
1561
+ fs::create_dir_all(parent).map_err(|_| {
1562
+ CompileError::CreateDirError(parent.display().to_string())
1563
+ })?;
1564
+ }
1565
+ atomic_write(&fragment_path, &page.fragment).map_err(|e| CompileError::WriteError {
1566
+ path: fragment_path.display().to_string(),
1567
+ source: e,
1568
+ })?;
1569
+
1570
+ pages.push(page);
1571
+ }
1572
+
1573
+ // The fragments dir mirrors the pages tree: prune fragments whose
1574
+ // page no longer exists (watch-mode page removals, no-clean runs).
1575
+ let fragments_root = self.config.output.join("components").join("vibe-spa");
1576
+ let live: HashSet<String> = pages.iter().map(|p| p.rel_path.clone()).collect();
1577
+ let mut existing = Vec::new();
1578
+ Self::collect_page_files(&fragments_root, &self.config.skip_files, &mut existing);
1579
+ for stale in existing {
1580
+ let rel = stale
1581
+ .strip_prefix(&fragments_root)
1582
+ .unwrap()
1583
+ .to_string_lossy()
1584
+ .replace('\\', "/");
1585
+ if !live.contains(&rel) {
1586
+ let _ = fs::remove_file(&stale);
1587
+ }
1588
+ }
1589
+
1590
+ let order = spa::route_order(&pages);
1591
+ let shell = spa::compose_shell(&pages, &order);
1592
+ if self.verbose {
1593
+ for note in &shell.notes {
1594
+ println!(" spa: {}", note);
1595
+ }
1596
+ }
1597
+
1598
+ if self.config.source.join("index.html").exists() {
1599
+ eprintln!(
1600
+ "{}: SPA shell overwrites the compiled root index.html (source has its own index.html outside the pages tree)",
1601
+ "Warning".yellow()
1602
+ );
1603
+ }
1604
+
1605
+ let shell_html = if self.config.minify {
1606
+ minify_html(&shell.html)
1607
+ } else {
1608
+ shell.html
1609
+ };
1610
+ let shell_path = self.config.output.join("index.html");
1611
+ atomic_write(&shell_path, &shell_html).map_err(|e| CompileError::WriteError {
1612
+ path: shell_path.display().to_string(),
1613
+ source: e,
1614
+ })?;
1615
+ // The shell rides the normal manifest pipeline like any page.
1616
+ self.compiled_html.insert(shell_path, shell_html);
1617
+
1618
+ Ok(pages.len() + 1)
1619
+ }
1620
+
1621
+ /// Regenerate the shell's manifest after a watch-mode recompose (the full
1622
+ /// build gets it via generate_manifests; incremental recompiles target
1623
+ /// only changed files, and the shell has no source counterpart to list).
1624
+ /// Manifest yes, stamp no — same rule as the full pipeline.
1625
+ pub(crate) fn generate_shell_manifest(&self) -> Result<(), CompileError> {
1626
+ let shell_path = self.config.output.join("index.html");
1627
+ let disk_html;
1628
+ let html: &str = match self.compiled_html.get(&shell_path) {
1629
+ Some(html) => html,
1630
+ None => {
1631
+ disk_html = fs::read_to_string(&shell_path).map_err(|e| CompileError::ReadError {
1632
+ path: shell_path.display().to_string(),
1633
+ source: e,
1634
+ })?;
1635
+ &disk_html
1636
+ }
1637
+ };
1638
+ Self::generate_file_manifest(
1639
+ html,
1640
+ &shell_path,
1641
+ &self.config.output,
1642
+ "index.html",
1643
+ self.verbose,
1644
+ self.config.iterations_as_is,
1645
+ self.config.components_as_is,
1646
+ &self.config.source,
1647
+ self.config.root.as_deref(),
1648
+ &self.compute_global_constants(),
1649
+ false,
1650
+ )
1651
+ .map_err(CompileError::SpaError)
1652
+ }
1653
+
1416
1654
  fn extract_components(&self, content: &str) -> (usize, usize, Vec<String>) {
1417
1655
  let mut internal = 0;
1418
1656
  let mut external = 0;
@@ -1493,6 +1731,47 @@ impl Compiler {
1493
1731
  Ok(())
1494
1732
  }
1495
1733
 
1734
+ /// Re-mirror specific source files under the components directory into the
1735
+ /// output. The full build always mirrors components/ verbatim (see
1736
+ /// process_directory_assets_only): runtime-fetched components —
1737
+ /// `<component src="@[page.src]">` targets, iter-prop each-roots,
1738
+ /// components_as_is — are served from that mirror at request time. Watch
1739
+ /// mode calls this for every changed component so the mirror tracks edits
1740
+ /// and deletions even when no page inlines the component (zero graph
1741
+ /// dependents). Writes are atomic (same tmp+rename as compiled pages) so a
1742
+ /// runtime fetch mid-copy never sees a torn file. Returns how many files
1743
+ /// were copied.
1744
+ pub fn mirror_component_files(&self, files: &[PathBuf]) -> Result<usize, CompileError> {
1745
+ let mut copied = 0;
1746
+ for path in files {
1747
+ let relative = path.strip_prefix(&self.config.source).unwrap_or(path);
1748
+ let output_path = self.config.output.join(relative);
1749
+ if path.exists() {
1750
+ if let Some(parent) = output_path.parent() {
1751
+ fs::create_dir_all(parent).map_err(|e| CompileError::WriteError {
1752
+ path: parent.display().to_string(),
1753
+ source: e,
1754
+ })?;
1755
+ }
1756
+ let contents = fs::read_to_string(path).map_err(|e| CompileError::ReadError {
1757
+ path: path.display().to_string(),
1758
+ source: e,
1759
+ })?;
1760
+ atomic_write(&output_path, &contents).map_err(|e| CompileError::WriteError {
1761
+ path: output_path.display().to_string(),
1762
+ source: e,
1763
+ })?;
1764
+ copied += 1;
1765
+ } else if output_path.exists() {
1766
+ fs::remove_file(&output_path).map_err(|e| CompileError::WriteError {
1767
+ path: output_path.display().to_string(),
1768
+ source: e,
1769
+ })?;
1770
+ }
1771
+ }
1772
+ Ok(copied)
1773
+ }
1774
+
1496
1775
  /// Fetch components only for specific files (used in incremental compilation)
1497
1776
  fn fetch_components_for_files(&mut self, files: &[PathBuf], parser: &HtmlParser) -> Result<(), CompileError> {
1498
1777
  for html_file in files {
@@ -2165,6 +2444,14 @@ fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), CompileError> {
2165
2444
  let dest_path = dest.join(&file_name);
2166
2445
 
2167
2446
  if path.is_dir() {
2447
+ // Cache Directory Tagging spec: a directory carrying a signed
2448
+ // CACHEDIR.TAG (cargo target/, many build caches) declares itself
2449
+ // regenerable and skippable for copy tools. A symlinked local
2450
+ // package would otherwise drag gigabytes of build artifacts into
2451
+ // the as-is node_modules copy.
2452
+ if is_cachedir_tagged(&path) {
2453
+ continue;
2454
+ }
2168
2455
  copy_dir_recursive(&path, &dest_path)?;
2169
2456
  } else {
2170
2457
  fs::copy(&path, &dest_path).map_err(|e| CompileError::WriteError {
@@ -2177,6 +2464,12 @@ fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), CompileError> {
2177
2464
  Ok(())
2178
2465
  }
2179
2466
 
2467
+ fn is_cachedir_tagged(dir: &Path) -> bool {
2468
+ fs::read(dir.join("CACHEDIR.TAG"))
2469
+ .map(|bytes| bytes.starts_with(b"Signature: 8a477f597d28d172789f06886806bc55"))
2470
+ .unwrap_or(false)
2471
+ }
2472
+
2180
2473
  /// Basic HTML minification
2181
2474
  fn minify_html(html: &str) -> String {
2182
2475
  let mut result = String::with_capacity(html.len());
@@ -2328,6 +2621,111 @@ mod tests {
2328
2621
  // A minimal on-disk project: source with one page carrying a distinctive
2329
2622
  // binding, empty components dir, output dir sibling. Returns (config, page
2330
2623
  // source path, compiled page output path, manifest path).
2624
+ // nodeModulesAsIs copies must skip cache-tagged directories (the Cache
2625
+ // Directory Tagging spec: cargo target/, many build caches). A symlinked
2626
+ // local package dragging its cargo target/ along turned a 15MB copy into
2627
+ // gigabytes.
2628
+ #[test]
2629
+ fn as_is_copy_skips_cachedir_tagged_directories() {
2630
+ let dir = std::env::temp_dir().join("vibe-cachedir-copy-test");
2631
+ let _ = fs::remove_dir_all(&dir);
2632
+ let src = dir.join("node_modules/pkg");
2633
+ fs::create_dir_all(src.join("runtime")).unwrap();
2634
+ fs::create_dir_all(src.join("build/target/release")).unwrap();
2635
+ fs::write(src.join("index.js"), "export default 1;").unwrap();
2636
+ fs::write(src.join("runtime/state.js"), "export const s = 1;").unwrap();
2637
+ fs::write(
2638
+ src.join("build/target/CACHEDIR.TAG"),
2639
+ "Signature: 8a477f597d28d172789f06886806bc55\n",
2640
+ )
2641
+ .unwrap();
2642
+ fs::write(src.join("build/target/release/artifact"), "big").unwrap();
2643
+
2644
+ let dest = dir.join("out/node_modules");
2645
+ copy_dir_recursive(&dir.join("node_modules"), &dest).unwrap();
2646
+
2647
+ assert!(dest.join("pkg/index.js").exists());
2648
+ assert!(dest.join("pkg/runtime/state.js").exists());
2649
+ assert!(dest.join("pkg/build").exists());
2650
+ assert!(!dest.join("pkg/build/target").exists(), "cache-tagged dir was copied");
2651
+
2652
+ let _ = fs::remove_dir_all(&dir);
2653
+ }
2654
+
2655
+ // Watch mode re-runs compile_spa on any pages-tree change: an edited page
2656
+ // re-transforms and the shell recomposes (title/head/route table), a
2657
+ // removed page's fragment is pruned from the output mirror and its route
2658
+ // leaves the table. Full-rerun semantics keep add/edit/remove one path.
2659
+ #[test]
2660
+ fn spa_recompile_syncs_fragments_shell_and_routes() {
2661
+ let dir = std::env::temp_dir().join("vibe-spa-watch-test");
2662
+ let _ = fs::remove_dir_all(&dir);
2663
+ let source = dir.join("src");
2664
+ fs::create_dir_all(source.join("pages")).unwrap();
2665
+ fs::create_dir_all(source.join("components")).unwrap();
2666
+ let page = |title: &str, body: &str| {
2667
+ format!(
2668
+ "<!DOCTYPE html>\n<html><head><title>{}</title></head>\n<body vibe-fouc>{}</body></html>\n",
2669
+ title, body
2670
+ )
2671
+ };
2672
+ fs::write(source.join("pages/index.html"), page("Home", "<h1>home</h1>")).unwrap();
2673
+ fs::write(source.join("pages/about.html"), page("About", "<h1>about</h1>")).unwrap();
2674
+
2675
+ let config = Config {
2676
+ source: source.clone(),
2677
+ output: dir.join("out"),
2678
+ _source_str: String::new(),
2679
+ _output_str: String::new(),
2680
+ components: "components".to_string(),
2681
+ pages: "pages".to_string(),
2682
+ _assets: String::new(),
2683
+ root: None,
2684
+ minify: false,
2685
+ elements_as_is: true,
2686
+ source_maps: false,
2687
+ reserved_elements: Vec::new(),
2688
+ skip_files: Vec::new(),
2689
+ node_modules_as_is: false,
2690
+ components_as_is: true,
2691
+ runtime_as_is: false,
2692
+ iterations_as_is: false,
2693
+ no_clean: false,
2694
+ fouc_as_is: false,
2695
+ spa: true,
2696
+ working_dir: dir.clone(),
2697
+ };
2698
+
2699
+ let mut compiler = Compiler::new(config.clone(), false);
2700
+ let mut parser = HtmlParser::new(config.components_path());
2701
+ parser.load_elements().unwrap();
2702
+ compiler.compile().unwrap();
2703
+
2704
+ let shell_path = config.output.join("index.html");
2705
+ let fragment = config.output.join("components/vibe-spa/about.html");
2706
+ let shell = fs::read_to_string(&shell_path).unwrap();
2707
+ assert!(shell.contains("\"route\": \"/about\""));
2708
+ assert!(shell.contains("<title>Home</title>"));
2709
+ assert!(fragment.exists());
2710
+
2711
+ // Edited page: fragment re-transforms, shell recomposes with the new
2712
+ // harvested title.
2713
+ fs::write(source.join("pages/about.html"), page("Regenerated", "<h1>about v2</h1>")).unwrap();
2714
+ compiler.compile_spa(&parser).unwrap();
2715
+ let shell = fs::read_to_string(&shell_path).unwrap();
2716
+ assert!(shell.contains("\"title\": \"Regenerated\""));
2717
+ assert!(fs::read_to_string(&fragment).unwrap().contains("about v2"));
2718
+
2719
+ // Removed page: route leaves the table, orphan fragment is pruned.
2720
+ fs::remove_file(source.join("pages/about.html")).unwrap();
2721
+ compiler.compile_spa(&parser).unwrap();
2722
+ let shell = fs::read_to_string(&shell_path).unwrap();
2723
+ assert!(!shell.contains("\"route\": \"/about\""));
2724
+ assert!(!fragment.exists());
2725
+
2726
+ let _ = fs::remove_dir_all(&dir);
2727
+ }
2728
+
2331
2729
  fn manifest_test_project(name: &str) -> (Config, PathBuf, PathBuf, PathBuf) {
2332
2730
  let dir = std::env::temp_dir().join(format!("vibe_{}_test", name));
2333
2731
  let _ = fs::remove_dir_all(&dir);
@@ -2361,6 +2759,7 @@ mod tests {
2361
2759
  iterations_as_is: false,
2362
2760
  no_clean: false,
2363
2761
  fouc_as_is: false,
2762
+ spa: false,
2364
2763
  working_dir: dir.clone(),
2365
2764
  };
2366
2765
 
@@ -2370,6 +2769,57 @@ mod tests {
2370
2769
  (config, page_src, page_out, manifest)
2371
2770
  }
2372
2771
 
2772
+ // The full build always mirrors components/ verbatim into the output —
2773
+ // runtime-fetched components (`<component src="@[page.src]">`, iter-prop
2774
+ // roots, components_as_is) are served from that mirror. Watch mode must
2775
+ // keep the same contract: an edited component reaches the mirror even when
2776
+ // NO page inlines it (zero dependents), and a deleted component leaves it.
2777
+ #[test]
2778
+ fn changed_component_is_remirrored_to_output() {
2779
+ let (config, _page_src, _page_out, _manifest) = manifest_test_project("component_mirror");
2780
+ let source_component = config.source.join("components").join("widget.html");
2781
+ let mirrored = config.output.join("components").join("widget.html");
2782
+ let nested_src = config.source.join("components").join("spa").join("pane.html");
2783
+ let nested_out = config.output.join("components").join("spa").join("pane.html");
2784
+ fs::write(&source_component, "<spa-widget>v1</spa-widget>\n").unwrap();
2785
+
2786
+ let mut compiler = Compiler::new(config, false);
2787
+ compiler.compile().expect("compile should succeed");
2788
+ assert_eq!(
2789
+ fs::read_to_string(&mirrored).unwrap(),
2790
+ "<spa-widget>v1</spa-widget>\n",
2791
+ "sanity: full build mirrors the component"
2792
+ );
2793
+
2794
+ // Edit the component — no page references it, so the dependency graph
2795
+ // maps it to zero pages; the mirror must still track the change.
2796
+ fs::write(&source_component, "<spa-widget>v2</spa-widget>\n").unwrap();
2797
+ let copied = compiler
2798
+ .mirror_component_files(&[source_component.clone()])
2799
+ .expect("mirroring should succeed");
2800
+ assert_eq!(copied, 1);
2801
+ assert_eq!(
2802
+ fs::read_to_string(&mirrored).unwrap(),
2803
+ "<spa-widget>v2</spa-widget>\n",
2804
+ "edited component did not reach the output mirror"
2805
+ );
2806
+
2807
+ // A component created mid-session (parent dirs may not exist yet).
2808
+ fs::create_dir_all(nested_src.parent().unwrap()).unwrap();
2809
+ fs::write(&nested_src, "<spa-pane>new</spa-pane>\n").unwrap();
2810
+ compiler
2811
+ .mirror_component_files(&[nested_src.clone()])
2812
+ .expect("mirroring a new nested component should succeed");
2813
+ assert_eq!(fs::read_to_string(&nested_out).unwrap(), "<spa-pane>new</spa-pane>\n");
2814
+
2815
+ // Deleting the source removes the mirrored copy.
2816
+ fs::remove_file(&source_component).unwrap();
2817
+ compiler
2818
+ .mirror_component_files(&[source_component])
2819
+ .expect("mirroring a deletion should succeed");
2820
+ assert!(!mirrored.exists(), "deleted component still present in the output mirror");
2821
+ }
2822
+
2373
2823
  // The watcher race: another writer truncates/rewrites a compiled page on
2374
2824
  // disk between our compile and our manifest pass. The manifest must be
2375
2825
  // built from the HTML this compiler just produced in memory — never from a
@@ -1,4 +1,5 @@
1
1
  pub mod compile;
2
+ pub mod spa;
2
3
  mod state_extractor;
3
4
  mod manifest_builder;
4
5
  mod value_stamper;