@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.
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.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
@@ -1599,7 +1599,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1599
1599
 
1600
1600
  [[package]]
1601
1601
  name = "vibe-compiler"
1602
- version = "2.2.0"
1602
+ version = "3.0.2"
1603
1603
  dependencies = [
1604
1604
  "clap",
1605
1605
  "colored",
package/src/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vibe-compiler"
3
- version = "2.2.0"
3
+ version = "3.0.2"
4
4
  edition = "2021"
5
5
  description = "Vibe framework compiler - compiles Vibe source files into optimized output"
6
6
  authors = ["Kim Korte"]
@@ -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
- !(spa && relative_path == "index.html"),
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: bool,
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 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 {
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 = !(spa && relative_path == "index.html");
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 yes, stamp no — same rule as the full pipeline.
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
- false,
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
- serde_json::Number::from_f64(n.value)
378
- .map(Value::Number)
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
- if result.is_empty() {
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
- if result.is_empty() {
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 mut analyzer = JsAnalyzer::new(PathBuf::from("/tmp"));
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 mut analyzer = JsAnalyzer::new(PathBuf::from("/tmp"));
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
- result = self.cleanup_unresolved_attributes(result);
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
+