@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.
- package/native/vibe-compiler-linux-x64 +0 -0
- package/package.json +1 -1
- package/src/Cargo.toml +1 -1
- package/src/compiler/compile.rs +161 -165
- package/src/compiler/value_stamper.rs +115 -71
- package/src/compiler/watcher.rs +6 -3
- package/src/config.rs +10 -0
- package/src/main.rs +7 -2
- package/src/parser/html.rs +1 -1
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ape-egg/vibe-compiler",
|
|
3
|
-
"version": "4.3.
|
|
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
package/src/compiler/compile.rs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
use std::fs;
|
|
2
2
|
use std::path::{Path, PathBuf};
|
|
3
|
-
use std::
|
|
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
|
-
///
|
|
43
|
-
///
|
|
44
|
-
///
|
|
45
|
-
///
|
|
46
|
-
|
|
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
|
|
330
|
-
|
|
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
|
-
//
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
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
|
-
|
|
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
|
-
///
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
let
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
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
|
-
///
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
}
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
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
|
-
///
|
|
2589
|
-
fn
|
|
2590
|
-
let
|
|
2591
|
-
.
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
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
|
-
|
|
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
|
|
2794
|
-
let dir = std::env::temp_dir().join("
|
|
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
|
-
|
|
2768
|
+
write_in_place(&target, "first").unwrap();
|
|
2800
2769
|
assert_eq!(fs::read_to_string(&target).unwrap(), "first");
|
|
2801
2770
|
|
|
2802
|
-
|
|
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::
|
|
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
|
-
|
|
23
|
-
|
|
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
|
|
41
|
-
pub fn new(state: &
|
|
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: &
|
|
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`
|
|
48
|
+
// time — mirroring the runtime, where `$.unsafe` wraps it in RawHtml.
|
|
61
49
|
ctx.eval::<(), _>(format!(
|
|
62
|
-
"const $ = {}; Object.assign(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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
|
|
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 `=>`
|
|
685
|
+
// and `"` in an attribute binding reads `"` — 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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
|
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 = "<component src="/card.html"></component> & 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 `=>`.
|
|
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 => 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 => 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
|
|
package/src/compiler/watcher.rs
CHANGED
|
@@ -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
|
|
529
|
-
println!("*
|
|
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
|
|
349
|
-
println!("*
|
|
353
|
+
} else {
|
|
354
|
+
println!("* copied dependencies in {:.0}ms", nm_time);
|
|
350
355
|
}
|
|
351
356
|
}
|
|
352
357
|
|
package/src/parser/html.rs
CHANGED
|
@@ -681,7 +681,7 @@ fn escape_attr_value(value: &str) -> String {
|
|
|
681
681
|
.replace('\'', "'")
|
|
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(""", "\"")
|
|
687
687
|
.replace(""", "\"")
|