@ape-egg/vibe-compiler 3.0.0 → 3.0.2
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-darwin-arm64 +0 -0
- package/native/vibe-compiler-linux-x64 +0 -0
- package/package.json +1 -1
- package/src/Cargo.lock +1 -1
- package/src/Cargo.toml +1 -1
- package/src/compiler/compile.rs +244 -13
- package/src/compiler/js_analyzer.rs +35 -9
- package/src/compiler/value_stamper.rs +94 -3
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ape-egg/vibe-compiler",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.2",
|
|
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.lock
CHANGED
package/src/Cargo.toml
CHANGED
package/src/compiler/compile.rs
CHANGED
|
@@ -550,6 +550,20 @@ impl VerboseLogger {
|
|
|
550
550
|
}
|
|
551
551
|
}
|
|
552
552
|
|
|
553
|
+
/// How a compiled page's HTML gets value-stamped after its manifest is built.
|
|
554
|
+
#[derive(Clone, Copy, PartialEq)]
|
|
555
|
+
pub(crate) enum StampMode {
|
|
556
|
+
/// Stamp everything resolvable and strip unresolved attribute markers —
|
|
557
|
+
/// regular compiled pages (the manifest restores what was stripped).
|
|
558
|
+
Full,
|
|
559
|
+
/// Stamp only what resolves statically; unresolved bindings, attributes
|
|
560
|
+
/// and conditionals stay byte-for-byte raw — the SPA shell, whose page
|
|
561
|
+
/// state is location-dependent and runtime-owned.
|
|
562
|
+
Selective,
|
|
563
|
+
/// Manifest only, HTML untouched.
|
|
564
|
+
Skip,
|
|
565
|
+
}
|
|
566
|
+
|
|
553
567
|
pub struct Compiler {
|
|
554
568
|
config: Config,
|
|
555
569
|
verbose: bool,
|
|
@@ -966,7 +980,7 @@ impl Compiler {
|
|
|
966
980
|
&source_root,
|
|
967
981
|
manifest_root.as_deref(),
|
|
968
982
|
&global_constants,
|
|
969
|
-
|
|
983
|
+
if spa && relative_path == "index.html" { StampMode::Selective } else { StampMode::Full },
|
|
970
984
|
) {
|
|
971
985
|
Ok(()) => true,
|
|
972
986
|
Err(e) => {
|
|
@@ -1003,7 +1017,7 @@ impl Compiler {
|
|
|
1003
1017
|
source_root: &Path,
|
|
1004
1018
|
manifest_root: Option<&str>,
|
|
1005
1019
|
global_constants: &Map<String, Value>,
|
|
1006
|
-
stamp:
|
|
1020
|
+
stamp: StampMode,
|
|
1007
1021
|
) -> Result<(), String> {
|
|
1008
1022
|
use crate::compiler::manifest_builder::ManifestBuilder;
|
|
1009
1023
|
use crate::compiler::component_tagger::ComponentTagger;
|
|
@@ -1060,15 +1074,18 @@ impl Compiler {
|
|
|
1060
1074
|
// The manifest (written above) preserves BOTH branches so the runtime can
|
|
1061
1075
|
// restore them and switch between branches reactively.
|
|
1062
1076
|
//
|
|
1063
|
-
// The SPA shell
|
|
1064
|
-
//
|
|
1065
|
-
//
|
|
1066
|
-
//
|
|
1067
|
-
//
|
|
1068
|
-
//
|
|
1069
|
-
|
|
1077
|
+
// The SPA shell stamps SELECTIVELY: its page state is location-dependent
|
|
1078
|
+
// and it is served at EVERY route path, so anything unresolvable — the
|
|
1079
|
+
// `@[page.src]` outlet, page.* conditionals, unresolved attribute
|
|
1080
|
+
// markers — stays raw for the runtime to process at any URL. Whatever
|
|
1081
|
+
// DOES resolve statically stamps like any compiled page, which drops
|
|
1082
|
+
// iteration template rows from the painted DOM (they are live elements
|
|
1083
|
+
// otherwise: their CSS animations run and their inline handlers fire
|
|
1084
|
+
// before the vibe-module chain defines the handler functions).
|
|
1085
|
+
if stamp != StampMode::Skip {
|
|
1070
1086
|
let stamper = ValueStamper::with_constants(&state, components_as_is, global_constants)
|
|
1071
|
-
.map_err(|e| format!("Failed to create value stamper: {}", e))
|
|
1087
|
+
.map_err(|e| format!("Failed to create value stamper: {}", e))?
|
|
1088
|
+
.selective(stamp == StampMode::Selective);
|
|
1072
1089
|
let stamped = stamper.stamp_html(html.to_string())
|
|
1073
1090
|
.map_err(|e| format!("Failed to stamp HTML: {}", e))?;
|
|
1074
1091
|
|
|
@@ -1186,7 +1203,7 @@ impl Compiler {
|
|
|
1186
1203
|
|
|
1187
1204
|
// Try to generate manifest for this file (skip on error).
|
|
1188
1205
|
// The SPA shell keeps its raw bindings (manifest yes, stamp no).
|
|
1189
|
-
let stamp =
|
|
1206
|
+
let stamp = if spa && relative_path == "index.html" { StampMode::Selective } else { StampMode::Full };
|
|
1190
1207
|
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) {
|
|
1191
1208
|
Ok(()) => (true, None),
|
|
1192
1209
|
Err(e) => {
|
|
@@ -1516,6 +1533,16 @@ impl Compiler {
|
|
|
1516
1533
|
)));
|
|
1517
1534
|
}
|
|
1518
1535
|
|
|
1536
|
+
// The component cache may be partially invalidated (watch mode drops
|
|
1537
|
+
// an edited component plus its inlining ancestors before this pass).
|
|
1538
|
+
// Re-fetch everything these pages reference — a cache miss during
|
|
1539
|
+
// inlining is silent and would degrade the shell and fragments to raw
|
|
1540
|
+
// runtime references. Warm entries return immediately, so a full
|
|
1541
|
+
// build pays nothing.
|
|
1542
|
+
if !self.config.components_as_is {
|
|
1543
|
+
self.fetch_components_for_files(&page_files, parser)?;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1519
1546
|
// Raw pre-pass: read every page and detect the shared-layout shape
|
|
1520
1547
|
// BEFORE component inlining consumes the <component src> root.
|
|
1521
1548
|
let mut raw_pages = Vec::new();
|
|
@@ -1726,7 +1753,7 @@ impl Compiler {
|
|
|
1726
1753
|
/// Regenerate the shell's manifest after a watch-mode recompose (the full
|
|
1727
1754
|
/// build gets it via generate_manifests; incremental recompiles target
|
|
1728
1755
|
/// only changed files, and the shell has no source counterpart to list).
|
|
1729
|
-
/// Manifest
|
|
1756
|
+
/// Manifest plus the selective stamp — same rule as the full pipeline.
|
|
1730
1757
|
pub(crate) fn generate_shell_manifest(&self) -> Result<(), CompileError> {
|
|
1731
1758
|
let shell_path = self.config.output.join("index.html");
|
|
1732
1759
|
let disk_html;
|
|
@@ -1751,7 +1778,7 @@ impl Compiler {
|
|
|
1751
1778
|
&self.config.source,
|
|
1752
1779
|
self.config.root.as_deref(),
|
|
1753
1780
|
&self.compute_global_constants(),
|
|
1754
|
-
|
|
1781
|
+
StampMode::Selective,
|
|
1755
1782
|
)
|
|
1756
1783
|
.map_err(CompileError::SpaError)
|
|
1757
1784
|
}
|
|
@@ -2831,6 +2858,210 @@ mod tests {
|
|
|
2831
2858
|
let _ = fs::remove_dir_all(&dir);
|
|
2832
2859
|
}
|
|
2833
2860
|
|
|
2861
|
+
// Watch mode invalidates an edited component plus its inlining ancestors,
|
|
2862
|
+
// then re-runs compile_spa through the same cache. The pass must re-fetch
|
|
2863
|
+
// what it needs: a partially invalidated cache silently degrades inlining
|
|
2864
|
+
// to raw runtime references (lean outlet-only shell, un-inlined
|
|
2865
|
+
// fragments), so incremental output must be byte-identical to a cold full
|
|
2866
|
+
// compile of the same source.
|
|
2867
|
+
#[test]
|
|
2868
|
+
fn spa_recompile_after_component_invalidation_matches_full_compile() {
|
|
2869
|
+
let dir = std::env::temp_dir().join("vibe-spa-invalidation-test");
|
|
2870
|
+
let _ = fs::remove_dir_all(&dir);
|
|
2871
|
+
let source = dir.join("src");
|
|
2872
|
+
fs::create_dir_all(source.join("pages")).unwrap();
|
|
2873
|
+
fs::create_dir_all(source.join("components")).unwrap();
|
|
2874
|
+
let page = |title: &str, content: &str| {
|
|
2875
|
+
format!(
|
|
2876
|
+
"<!DOCTYPE html>\n<html><head><title>{}</title></head>\n<body vibe-fouc>\
|
|
2877
|
+
<component src=\"/components/Layout.html\">{}</component></body></html>\n",
|
|
2878
|
+
title, content
|
|
2879
|
+
)
|
|
2880
|
+
};
|
|
2881
|
+
fs::write(
|
|
2882
|
+
source.join("components/Layout.html"),
|
|
2883
|
+
"<nav-chrome><component src=\"/components/Menu.html\"></component></nav-chrome>\n<slot></slot>\n",
|
|
2884
|
+
)
|
|
2885
|
+
.unwrap();
|
|
2886
|
+
fs::write(source.join("components/Menu.html"), "<menu-box>menu-v1</menu-box>\n").unwrap();
|
|
2887
|
+
fs::write(source.join("components/Widget.html"), "<widget-box>widget-v1</widget-box>\n").unwrap();
|
|
2888
|
+
fs::write(
|
|
2889
|
+
source.join("pages/index.html"),
|
|
2890
|
+
page("Home", "<h1>home</h1><component src=\"/components/Widget.html\"></component>"),
|
|
2891
|
+
)
|
|
2892
|
+
.unwrap();
|
|
2893
|
+
fs::write(source.join("pages/about.html"), page("About", "<h1>about</h1>")).unwrap();
|
|
2894
|
+
|
|
2895
|
+
let config = Config {
|
|
2896
|
+
source: source.clone(),
|
|
2897
|
+
output: dir.join("out"),
|
|
2898
|
+
_source_str: String::new(),
|
|
2899
|
+
_output_str: String::new(),
|
|
2900
|
+
components: "components".to_string(),
|
|
2901
|
+
pages: "pages".to_string(),
|
|
2902
|
+
_assets: String::new(),
|
|
2903
|
+
root: None,
|
|
2904
|
+
minify: false,
|
|
2905
|
+
elements_as_is: true,
|
|
2906
|
+
source_maps: false,
|
|
2907
|
+
reserved_elements: Vec::new(),
|
|
2908
|
+
skip_files: Vec::new(),
|
|
2909
|
+
node_modules_as_is: false,
|
|
2910
|
+
components_as_is: false,
|
|
2911
|
+
runtime_as_is: false,
|
|
2912
|
+
iterations_as_is: false,
|
|
2913
|
+
no_clean: false,
|
|
2914
|
+
fouc_as_is: false,
|
|
2915
|
+
spa: true,
|
|
2916
|
+
working_dir: dir.clone(),
|
|
2917
|
+
};
|
|
2918
|
+
|
|
2919
|
+
let mut compiler = Compiler::new(config.clone(), false);
|
|
2920
|
+
let mut parser = HtmlParser::new(config.components_path());
|
|
2921
|
+
parser.load_elements().unwrap();
|
|
2922
|
+
compiler.compile().unwrap();
|
|
2923
|
+
compiler.generate_manifests().unwrap();
|
|
2924
|
+
|
|
2925
|
+
let shell_path = config.output.join("index.html");
|
|
2926
|
+
let manifest_path = config.output.join("vibe-hyperspeed/index.html.manifest.js");
|
|
2927
|
+
let fragment_path = config.output.join("components/vibe-spa/index.html");
|
|
2928
|
+
assert!(fs::read_to_string(&shell_path).unwrap().contains("menu-v1"));
|
|
2929
|
+
assert!(fs::read_to_string(&fragment_path).unwrap().contains("widget-v1"));
|
|
2930
|
+
|
|
2931
|
+
// Edit both a layout-subtree component and a page-content component,
|
|
2932
|
+
// then mirror the watcher: invalidate the edited components + their
|
|
2933
|
+
// inlining ancestors, re-run the SPA pass, refresh the shell manifest.
|
|
2934
|
+
fs::write(source.join("components/Menu.html"), "<menu-box>menu-v2</menu-box>\n").unwrap();
|
|
2935
|
+
fs::write(source.join("components/Widget.html"), "<widget-box>widget-v2</widget-box>\n").unwrap();
|
|
2936
|
+
let stale: std::collections::HashSet<String> = [
|
|
2937
|
+
"/components/Menu.html",
|
|
2938
|
+
"/components/Layout.html",
|
|
2939
|
+
"/components/Widget.html",
|
|
2940
|
+
]
|
|
2941
|
+
.into_iter()
|
|
2942
|
+
.map(String::from)
|
|
2943
|
+
.collect();
|
|
2944
|
+
compiler.invalidate_components(&stale);
|
|
2945
|
+
compiler.compile_spa(&parser).unwrap();
|
|
2946
|
+
compiler.generate_shell_manifest().unwrap();
|
|
2947
|
+
|
|
2948
|
+
let incremental_shell = fs::read_to_string(&shell_path).unwrap();
|
|
2949
|
+
let incremental_manifest = fs::read_to_string(&manifest_path).unwrap();
|
|
2950
|
+
let incremental_fragment = fs::read_to_string(&fragment_path).unwrap();
|
|
2951
|
+
|
|
2952
|
+
// Cold full compile of the identical source is the reference output.
|
|
2953
|
+
let mut full_compiler = Compiler::new(config.clone(), false);
|
|
2954
|
+
let mut full_parser = HtmlParser::new(config.components_path());
|
|
2955
|
+
full_parser.load_elements().unwrap();
|
|
2956
|
+
full_compiler.compile().unwrap();
|
|
2957
|
+
full_compiler.generate_manifests().unwrap();
|
|
2958
|
+
|
|
2959
|
+
assert_eq!(incremental_shell, fs::read_to_string(&shell_path).unwrap());
|
|
2960
|
+
assert_eq!(incremental_manifest, fs::read_to_string(&manifest_path).unwrap());
|
|
2961
|
+
assert_eq!(incremental_fragment, fs::read_to_string(&fragment_path).unwrap());
|
|
2962
|
+
|
|
2963
|
+
let _ = fs::remove_dir_all(&dir);
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
// The SPA shell can't be value-stamped wholesale — its page state is
|
|
2967
|
+
// location-dependent — but skipping the stamp entirely ships raw iteration
|
|
2968
|
+
// template rows as live DOM: they animate and their inline handlers fire
|
|
2969
|
+
// before the module chain defines the handler functions (the production
|
|
2970
|
+
// "onNotifyAnimEnd is not defined" crash). The shell stamps SELECTIVELY:
|
|
2971
|
+
// whatever resolves statically stamps exactly like a compiled page (which
|
|
2972
|
+
// drops iteration template rows — the manifest restores them at runtime),
|
|
2973
|
+
// while anything unresolvable — the outlet, page.* conditionals, raw
|
|
2974
|
+
// attribute markers, unknown globals — stays byte-for-byte raw for the
|
|
2975
|
+
// runtime, matching the unstamped shell's behavior.
|
|
2976
|
+
#[test]
|
|
2977
|
+
fn spa_shell_stamps_selectively() {
|
|
2978
|
+
let dir = std::env::temp_dir().join("vibe-spa-selective-stamp-test");
|
|
2979
|
+
let _ = fs::remove_dir_all(&dir);
|
|
2980
|
+
let source = dir.join("src");
|
|
2981
|
+
fs::create_dir_all(source.join("pages")).unwrap();
|
|
2982
|
+
fs::create_dir_all(source.join("components")).unwrap();
|
|
2983
|
+
fs::write(
|
|
2984
|
+
source.join("components/Layout.html"),
|
|
2985
|
+
"<notify-area>\n\
|
|
2986
|
+
<!-- each notifications as n (n.id) -->\n\
|
|
2987
|
+
<notify-card onanimationend=\"onEnd(n)\" kind=\"@[n.kind]\">@[n.title]</notify-card>\n\
|
|
2988
|
+
<!-- /each -->\n\
|
|
2989
|
+
</notify-area>\n\
|
|
2990
|
+
<static-branch><!-- if 1 === 1 -->stamped-true-branch<!-- else -->stamped-false-branch<!-- /if --></static-branch>\n\
|
|
2991
|
+
<route-branch><!-- if page.name === 'about' -->route-true-branch<!-- else -->route-false-branch<!-- /if --></route-branch>\n\
|
|
2992
|
+
<sum-stamp>@[2 + 3]</sum-stamp>\n\
|
|
2993
|
+
<raw-bind data-thing=\"@[page.name]\">@[season]</raw-bind>\n\
|
|
2994
|
+
<slot></slot>\n",
|
|
2995
|
+
)
|
|
2996
|
+
.unwrap();
|
|
2997
|
+
let page = |title: &str, content: &str| {
|
|
2998
|
+
format!(
|
|
2999
|
+
"<!DOCTYPE html>\n<html><head><title>{}</title></head>\n<body vibe-fouc>\
|
|
3000
|
+
<component src=\"/components/Layout.html\">{}</component></body></html>\n",
|
|
3001
|
+
title, content
|
|
3002
|
+
)
|
|
3003
|
+
};
|
|
3004
|
+
fs::write(source.join("pages/index.html"), page("Home", "<h1>home</h1>")).unwrap();
|
|
3005
|
+
fs::write(source.join("pages/about.html"), page("About", "<h1>about</h1>")).unwrap();
|
|
3006
|
+
|
|
3007
|
+
let config = Config {
|
|
3008
|
+
source: source.clone(),
|
|
3009
|
+
output: dir.join("out"),
|
|
3010
|
+
_source_str: String::new(),
|
|
3011
|
+
_output_str: String::new(),
|
|
3012
|
+
components: "components".to_string(),
|
|
3013
|
+
pages: "pages".to_string(),
|
|
3014
|
+
_assets: String::new(),
|
|
3015
|
+
root: None,
|
|
3016
|
+
minify: false,
|
|
3017
|
+
elements_as_is: true,
|
|
3018
|
+
source_maps: false,
|
|
3019
|
+
reserved_elements: Vec::new(),
|
|
3020
|
+
skip_files: Vec::new(),
|
|
3021
|
+
node_modules_as_is: false,
|
|
3022
|
+
components_as_is: false,
|
|
3023
|
+
runtime_as_is: false,
|
|
3024
|
+
iterations_as_is: false,
|
|
3025
|
+
no_clean: false,
|
|
3026
|
+
fouc_as_is: false,
|
|
3027
|
+
spa: true,
|
|
3028
|
+
working_dir: dir.clone(),
|
|
3029
|
+
};
|
|
3030
|
+
|
|
3031
|
+
let mut compiler = Compiler::new(config.clone(), false);
|
|
3032
|
+
let mut parser = HtmlParser::new(config.components_path());
|
|
3033
|
+
parser.load_elements().unwrap();
|
|
3034
|
+
compiler.compile().unwrap();
|
|
3035
|
+
compiler.generate_manifests().unwrap();
|
|
3036
|
+
|
|
3037
|
+
let shell = fs::read_to_string(config.output.join("index.html")).unwrap();
|
|
3038
|
+
|
|
3039
|
+
// The ghost template row is gone — nothing to animate, no handler to
|
|
3040
|
+
// fire — while the iteration site marker survives for the runtime.
|
|
3041
|
+
assert!(!shell.contains("notify-card"), "template row should be dropped: {shell}");
|
|
3042
|
+
assert!(!shell.contains("onEnd"), "template handler should be dropped");
|
|
3043
|
+
assert!(shell.contains("<!-- each notifications as n (n.id) -->"), "iteration marker should stay");
|
|
3044
|
+
|
|
3045
|
+
// Statically resolvable content stamps like any compiled page.
|
|
3046
|
+
assert!(shell.contains("stamped-true-branch"), "resolvable conditional should stamp");
|
|
3047
|
+
assert!(!shell.contains("stamped-false-branch"), "untaken branch should be emptied");
|
|
3048
|
+
assert!(shell.contains("<sum-stamp>5</sum-stamp>"), "resolvable binding should stamp");
|
|
3049
|
+
|
|
3050
|
+
// Location-dependent state stays raw: the outlet keeps its bindings,
|
|
3051
|
+
// page.* conditionals keep BOTH branches, unresolved attribute markers
|
|
3052
|
+
// and unknown-global text bindings are untouched.
|
|
3053
|
+
assert!(shell.contains(r#"src="@[page.src]""#), "outlet src must stay raw");
|
|
3054
|
+
assert!(shell.contains(r#"key="@[page.path]""#), "outlet key must stay raw");
|
|
3055
|
+
assert!(
|
|
3056
|
+
shell.contains("route-true-branch") && shell.contains("route-false-branch"),
|
|
3057
|
+
"page-dependent conditional must keep both branches raw"
|
|
3058
|
+
);
|
|
3059
|
+
assert!(shell.contains(r#"data-thing="@[page.name]""#), "unresolved attribute must not be stripped");
|
|
3060
|
+
assert!(shell.contains("@[season]"), "unknown-global binding must stay raw");
|
|
3061
|
+
|
|
3062
|
+
let _ = fs::remove_dir_all(&dir);
|
|
3063
|
+
}
|
|
3064
|
+
|
|
2834
3065
|
fn manifest_test_project(name: &str) -> (Config, PathBuf, PathBuf, PathBuf) {
|
|
2835
3066
|
let dir = std::env::temp_dir().join(format!("vibe_{}_test", name));
|
|
2836
3067
|
let _ = fs::remove_dir_all(&dir);
|
|
@@ -372,10 +372,14 @@ impl StateExtractor {
|
|
|
372
372
|
// String literal: "hello"
|
|
373
373
|
Expr::Lit(Lit::Str(s)) => Some(Value::String(s.value.to_string())),
|
|
374
374
|
|
|
375
|
-
// Number literal: 42
|
|
375
|
+
// Number literal: 42 — swc carries every numeric as f64; integral
|
|
376
|
+
// values must resolve to JSON integers or stamping paints "0.0".
|
|
376
377
|
Expr::Lit(Lit::Num(n)) => {
|
|
377
|
-
|
|
378
|
-
|
|
378
|
+
if n.value.fract() == 0.0 && n.value.abs() <= i64::MAX as f64 {
|
|
379
|
+
Some(Value::Number(serde_json::Number::from(n.value as i64)))
|
|
380
|
+
} else {
|
|
381
|
+
serde_json::Number::from_f64(n.value).map(Value::Number)
|
|
382
|
+
}
|
|
379
383
|
}
|
|
380
384
|
|
|
381
385
|
// Boolean literal: true/false
|
|
@@ -447,8 +451,10 @@ impl StateExtractor {
|
|
|
447
451
|
}
|
|
448
452
|
}
|
|
449
453
|
|
|
450
|
-
// Return partial object even if some properties couldn't be resolved
|
|
451
|
-
|
|
454
|
+
// Return partial object even if some properties couldn't be resolved.
|
|
455
|
+
// An authored-empty literal ({}) IS the resolved value — only a
|
|
456
|
+
// non-empty literal where nothing resolved counts as unresolvable.
|
|
457
|
+
if result.is_empty() && !obj.props.is_empty() {
|
|
452
458
|
None
|
|
453
459
|
} else {
|
|
454
460
|
Some(Value::Object(result))
|
|
@@ -481,8 +487,10 @@ impl StateExtractor {
|
|
|
481
487
|
}
|
|
482
488
|
}
|
|
483
489
|
|
|
484
|
-
// Return partial array even if some elements couldn't be resolved
|
|
485
|
-
|
|
490
|
+
// Return partial array even if some elements couldn't be resolved.
|
|
491
|
+
// An authored-empty literal ([]) IS the resolved value — only a
|
|
492
|
+
// non-empty literal where nothing resolved counts as unresolvable.
|
|
493
|
+
if result.is_empty() && !arr.elems.is_empty() {
|
|
486
494
|
None
|
|
487
495
|
} else {
|
|
488
496
|
Some(Value::Array(result))
|
|
@@ -603,7 +611,12 @@ mod tests {
|
|
|
603
611
|
|
|
604
612
|
#[test]
|
|
605
613
|
fn test_import_resolution() {
|
|
606
|
-
let
|
|
614
|
+
let dir = std::env::temp_dir().join("vibe_import_resolution_test");
|
|
615
|
+
let _ = fs::remove_dir_all(&dir);
|
|
616
|
+
fs::create_dir_all(&dir).unwrap();
|
|
617
|
+
fs::write(dir.join("test-import.js"), "export const data = { a: 1, b: 2 };\n").unwrap();
|
|
618
|
+
|
|
619
|
+
let mut analyzer = JsAnalyzer::new(dir.clone());
|
|
607
620
|
let script = r#"
|
|
608
621
|
import { data } from './test-import.js';
|
|
609
622
|
vibe({ ...data, c: 3 });
|
|
@@ -613,6 +626,8 @@ mod tests {
|
|
|
613
626
|
assert_eq!(state["a"].as_f64().unwrap(), 1.0);
|
|
614
627
|
assert_eq!(state["b"].as_f64().unwrap(), 2.0);
|
|
615
628
|
assert_eq!(state["c"].as_f64().unwrap(), 3.0);
|
|
629
|
+
|
|
630
|
+
let _ = fs::remove_dir_all(&dir);
|
|
616
631
|
}
|
|
617
632
|
|
|
618
633
|
#[test]
|
|
@@ -674,7 +689,16 @@ mod tests {
|
|
|
674
689
|
|
|
675
690
|
#[test]
|
|
676
691
|
fn test_absolute_path_import() {
|
|
677
|
-
let
|
|
692
|
+
let dir = std::env::temp_dir().join("vibe_absolute_import_test");
|
|
693
|
+
let _ = fs::remove_dir_all(&dir);
|
|
694
|
+
fs::create_dir_all(&dir).unwrap();
|
|
695
|
+
fs::write(
|
|
696
|
+
dir.join("test-data.js"),
|
|
697
|
+
"export const menuState = { menuSections: [], other: \"value\" };\n",
|
|
698
|
+
)
|
|
699
|
+
.unwrap();
|
|
700
|
+
|
|
701
|
+
let mut analyzer = JsAnalyzer::new(dir.clone());
|
|
678
702
|
let script = r#"
|
|
679
703
|
import { menuState } from '/test-data.js';
|
|
680
704
|
vibe({ ...menuState, extra: "value" });
|
|
@@ -686,6 +710,8 @@ mod tests {
|
|
|
686
710
|
assert!(state["menuSections"].is_array(), "menuSections should be an array");
|
|
687
711
|
assert_eq!(state["other"].as_str().unwrap(), "value");
|
|
688
712
|
assert_eq!(state["extra"].as_str().unwrap(), "value");
|
|
713
|
+
|
|
714
|
+
let _ = fs::remove_dir_all(&dir);
|
|
689
715
|
}
|
|
690
716
|
|
|
691
717
|
#[test]
|
|
@@ -71,6 +71,11 @@ pub struct ValueStamper<'a> {
|
|
|
71
71
|
/// one of these keys is stamped from here — never a compound expression — so
|
|
72
72
|
/// there is no risk of a dynamic operand leaking into the value.
|
|
73
73
|
constant_texts: HashMap<String, String>,
|
|
74
|
+
/// Selective mode (the SPA shell): unresolvable state is runtime-owned, so
|
|
75
|
+
/// unresolved attribute markers are kept instead of stripped and a
|
|
76
|
+
/// conditional whose expression reads an unknown root stays raw (both
|
|
77
|
+
/// branches) instead of stamping to its else branch.
|
|
78
|
+
selective: bool,
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
impl<'a> ValueStamper<'a> {
|
|
@@ -114,9 +119,15 @@ impl<'a> ValueStamper<'a> {
|
|
|
114
119
|
context,
|
|
115
120
|
components_as_is,
|
|
116
121
|
constant_texts,
|
|
122
|
+
selective: false,
|
|
117
123
|
})
|
|
118
124
|
}
|
|
119
125
|
|
|
126
|
+
pub fn selective(mut self, selective: bool) -> Self {
|
|
127
|
+
self.selective = selective;
|
|
128
|
+
self
|
|
129
|
+
}
|
|
130
|
+
|
|
120
131
|
pub fn stamp_html(&self, html: String) -> Result<String, String> {
|
|
121
132
|
// Extract vibe-dehydrate content to protect it from processing
|
|
122
133
|
// This prevents @[...] markers and iterations/conditionals from being processed
|
|
@@ -215,8 +226,12 @@ impl<'a> ValueStamper<'a> {
|
|
|
215
226
|
// Add remaining text
|
|
216
227
|
result.push_str(&html[last_pos..]);
|
|
217
228
|
|
|
218
|
-
// Clean up attributes that still contain unresolved markers
|
|
219
|
-
|
|
229
|
+
// Clean up attributes that still contain unresolved markers. Selective
|
|
230
|
+
// mode keeps them: an unresolved marker there is runtime-owned state
|
|
231
|
+
// (the outlet's `src="@[page.src]"`), not an absent iteration property.
|
|
232
|
+
if !self.selective {
|
|
233
|
+
result = self.cleanup_unresolved_attributes(result);
|
|
234
|
+
}
|
|
220
235
|
|
|
221
236
|
// Restore vibe-dehydrate content from placeholders
|
|
222
237
|
for (i, content) in dehydrate_placeholders.iter().enumerate() {
|
|
@@ -312,7 +327,8 @@ impl<'a> ValueStamper<'a> {
|
|
|
312
327
|
|
|
313
328
|
// Create temp stamper with merged state for this iteration
|
|
314
329
|
let temp_stamper = ValueStamper::new(&merged_state, false)
|
|
315
|
-
.map_err(|e| format!("Failed to create stamper for iteration: {}", e))
|
|
330
|
+
.map_err(|e| format!("Failed to create stamper for iteration: {}", e))?
|
|
331
|
+
.selective(self.selective);
|
|
316
332
|
|
|
317
333
|
// Render nested iterations first
|
|
318
334
|
let mut item_html = temp_stamper.render_iterations_recursive(template.clone())?;
|
|
@@ -651,6 +667,47 @@ impl<'a> ValueStamper<'a> {
|
|
|
651
667
|
}
|
|
652
668
|
|
|
653
669
|
fn try_eval_condition(&self, condition: &str) -> Option<bool> {
|
|
670
|
+
// Selective mode: a condition that reads a root the stamp state does
|
|
671
|
+
// not know (page.*, runtime-merged globals) must stay raw — stamping
|
|
672
|
+
// it to the else branch would pre-paint the wrong branch for state the
|
|
673
|
+
// runtime owns. Evaluate inside a `with` over a proxy that throws a
|
|
674
|
+
// marker on unknown roots; the marker means "keep raw" (None), while
|
|
675
|
+
// known-state conditions keep the exact semantics of the plain path.
|
|
676
|
+
if self.selective {
|
|
677
|
+
return self.context.with(|ctx| -> Option<bool> {
|
|
678
|
+
let probe = format!(
|
|
679
|
+
"if (!globalThis.__vibeStrictScope) {{
|
|
680
|
+
globalThis.__vibeStrictScope = new Proxy({{}}, {{
|
|
681
|
+
has: function() {{ return true; }},
|
|
682
|
+
get: function(t, k) {{
|
|
683
|
+
if (typeof k !== 'string') return undefined;
|
|
684
|
+
if (k in $) return $[k];
|
|
685
|
+
if (k in globalThis) return globalThis[k];
|
|
686
|
+
throw new Error('__VIBE_UNRESOLVED__');
|
|
687
|
+
}},
|
|
688
|
+
}});
|
|
689
|
+
}}
|
|
690
|
+
(function() {{
|
|
691
|
+
try {{
|
|
692
|
+
// `with` is illegal in strict-mode eval; a Function
|
|
693
|
+
// constructor body is sloppy by spec, so the scope
|
|
694
|
+
// proxy works there.
|
|
695
|
+
return new Function('__scope', 'with (__scope) {{ return !!({}); }}')(globalThis.__vibeStrictScope);
|
|
696
|
+
}} catch (e) {{
|
|
697
|
+
return e && e.message === '__VIBE_UNRESOLVED__' ? '__VIBE_UNRESOLVED__' : false;
|
|
698
|
+
}}
|
|
699
|
+
}})()",
|
|
700
|
+
condition.replace('\\', "\\\\").replace('\'', "\\'")
|
|
701
|
+
);
|
|
702
|
+
let result: rquickjs::Value = ctx.eval(probe.as_str()).ok()?;
|
|
703
|
+
if result.is_bool() {
|
|
704
|
+
return result.as_bool();
|
|
705
|
+
}
|
|
706
|
+
// The marker string: an unknown root — keep the block raw.
|
|
707
|
+
None
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
654
711
|
self.context.with(|ctx| -> Option<bool> {
|
|
655
712
|
// Try to evaluate condition
|
|
656
713
|
let result: rquickjs::Value = match ctx.eval(condition) {
|
|
@@ -746,6 +803,10 @@ impl<'a> ValueStamper<'a> {
|
|
|
746
803
|
} else if attr_value.is_empty() {
|
|
747
804
|
// Convert boolean attributes with empty values to short form: `demo=""` -> `demo`
|
|
748
805
|
format!(" {}", &caps[1])
|
|
806
|
+
} else if *attr_value == *"true" {
|
|
807
|
+
// Normalize to HTML5 boolean attribute syntax: `disabled="true"` -> `disabled=""`
|
|
808
|
+
// (matches the runtime, where a truthy pure binding sets the attribute, not the string)
|
|
809
|
+
format!(" {}=\"\"", &caps[1])
|
|
749
810
|
} else {
|
|
750
811
|
// Keep the attribute as-is
|
|
751
812
|
caps[0].to_string()
|
|
@@ -950,6 +1011,35 @@ mod tests {
|
|
|
950
1011
|
assert_eq!(result, r#"<input readonly="">"#);
|
|
951
1012
|
}
|
|
952
1013
|
|
|
1014
|
+
// Selective mode (the SPA shell): unresolved attribute markers are
|
|
1015
|
+
// runtime-owned bindings, not absent iteration properties — kept raw.
|
|
1016
|
+
#[test]
|
|
1017
|
+
fn selective_keeps_unresolved_attribute_markers() {
|
|
1018
|
+
let state = json!({ "known": "yes" });
|
|
1019
|
+
let stamper = ValueStamper::new(&state, false).unwrap().selective(true);
|
|
1020
|
+
let html = String::from(r#"<component src="@[page.src]" key="@[page.path]" title="@[known]"></component>"#);
|
|
1021
|
+
let result = stamper.stamp_html(html).unwrap();
|
|
1022
|
+
assert!(result.contains(r#"src="@[page.src]""#), "got: {result}");
|
|
1023
|
+
assert!(result.contains(r#"key="@[page.path]""#), "got: {result}");
|
|
1024
|
+
assert!(result.contains(r#"title="yes""#), "got: {result}");
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// Selective mode: a conditional reading an unknown root stays raw (both
|
|
1028
|
+
// branches, all markers) — the runtime owns that state. Known-root and
|
|
1029
|
+
// static conditionals stamp exactly like the plain path.
|
|
1030
|
+
#[test]
|
|
1031
|
+
fn selective_conditionals_stamp_only_known_roots() {
|
|
1032
|
+
let state = json!({ "flag": true });
|
|
1033
|
+
let stamper = ValueStamper::new(&state, false).unwrap().selective(true);
|
|
1034
|
+
let html = String::from(
|
|
1035
|
+
"<a><!-- if page.name === 'about' -->route-yes<!-- else -->route-no<!-- /if --></a>\
|
|
1036
|
+
<b><!-- if flag -->flag-yes<!-- else -->flag-no<!-- /if --></b>",
|
|
1037
|
+
);
|
|
1038
|
+
let result = stamper.stamp_html(html).unwrap();
|
|
1039
|
+
assert!(result.contains("route-yes") && result.contains("route-no"), "got: {result}");
|
|
1040
|
+
assert!(result.contains("flag-yes") && !result.contains("flag-no"), "got: {result}");
|
|
1041
|
+
}
|
|
1042
|
+
|
|
953
1043
|
fn constants(pairs: &[(&str, Value)]) -> Map<String, Value> {
|
|
954
1044
|
pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
|
|
955
1045
|
}
|
|
@@ -986,3 +1076,4 @@ mod tests {
|
|
|
986
1076
|
assert!(!result.contains("0.1.5"), "constant leaked into compound: {result}");
|
|
987
1077
|
}
|
|
988
1078
|
}
|
|
1079
|
+
|