@ape-egg/vibe-compiler 4.3.25 → 4.3.27

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.
@@ -1,35 +1,20 @@
1
1
  use regex::{Regex, Captures};
2
2
  use serde_json::{Map, Value};
3
3
  use rquickjs::{Context, Runtime};
4
- use std::collections::{HashMap, HashSet};
5
-
6
- /// Render a primitive state value the way an evaluated `@[expr]` would print it:
7
- /// strings/numbers/bools as their text, null as empty. Mirrors `eval_with_state`.
8
- fn primitive_to_text(value: &Value) -> String {
9
- match value {
10
- Value::String(s) => s.clone(),
11
- Value::Number(n) => n.to_string(),
12
- Value::Bool(b) => b.to_string(),
13
- Value::Null => String::new(),
14
- // Constants are primitive-only (see reassignment_analyzer); fall back safely.
15
- other => other.to_string(),
16
- }
17
- }
4
+ use std::collections::HashSet;
18
5
 
19
6
  use crate::constants::{NON_REACTIVE_ELEMENTS, VALUE_ATTRS, DEHYDRATE_ATTR, NAMEBIND_ATTR, DEFER_ATTR_PREFIX};
7
+ use crate::parser::html::decode_entities;
20
8
 
21
-
22
- pub struct ValueStamper<'a> {
23
- state: &'a Value,
9
+ pub struct ValueStamper {
10
+ /// Stamp state plus the global keys reassignment analysis proved constant.
11
+ /// A dynamic global is never in here, so any expression reading one throws
12
+ /// a ReferenceError in QuickJS and its binding stays live for the runtime.
13
+ state: Value,
24
14
  binding_regex: Regex,
25
15
  _runtime: Runtime, // Must be kept alive for context to work
26
16
  context: Context,
27
17
  components_as_is: bool,
28
- /// Global state keys proven constant by reassignment analysis, mapped to the
29
- /// text they stamp to. Only an `@[key]` binding whose expression is EXACTLY
30
- /// one of these keys is stamped from here — never a compound expression — so
31
- /// there is no risk of a dynamic operand leaking into the value.
32
- constant_texts: HashMap<String, String>,
33
18
  /// Selective mode (the SPA shell): unresolvable state is runtime-owned, so
34
19
  /// unresolved attribute markers are kept instead of stripped and a
35
20
  /// conditional whose expression reads an unknown root stays raw (both
@@ -37,47 +22,46 @@ pub struct ValueStamper<'a> {
37
22
  selective: bool,
38
23
  }
39
24
 
40
- impl<'a> ValueStamper<'a> {
41
- pub fn new(state: &'a Value, components_as_is: bool) -> Result<Self, String> {
25
+ impl ValueStamper {
26
+ pub fn new(state: &Value, components_as_is: bool) -> Result<Self, String> {
42
27
  Self::with_constants(state, components_as_is, &Map::new())
43
28
  }
44
29
 
45
30
  pub fn with_constants(
46
- state: &'a Value,
31
+ state: &Value,
47
32
  components_as_is: bool,
48
33
  constants: &Map<String, Value>,
49
34
  ) -> Result<Self, String> {
35
+ let mut state = state.clone();
36
+ state.as_object_mut().expect("stamp state is an object").extend(constants.clone());
37
+
50
38
  let runtime = Runtime::new().map_err(|e| format!("Failed to create QuickJS runtime: {:?}", e))?;
51
39
  let context = Context::full(&runtime).map_err(|e| format!("Failed to create QuickJS context: {:?}", e))?;
52
40
 
53
41
  // Set up state ONCE when creating the stamper (not per expression)
54
42
  context.with(|ctx| -> Result<(), String> {
55
- let state_str = serde_json::to_string(state)
43
+ let state_str = serde_json::to_string(&state)
56
44
  .map_err(|e| format!("Failed to serialize state: {}", e))?;
57
45
  // Expose state both as bare globals (via Object.assign) and as a
58
46
  // persistent `$` global carrying the runtime `unsafe` helper, so a
59
47
  // `@[$.unsafe(expr)]` binding stamps its trusted string raw at build
60
- // time — mirroring the runtime, where `$.unsafe` marks raw HTML.
48
+ // time — mirroring the runtime, where `$.unsafe` wraps it in RawHtml.
61
49
  ctx.eval::<(), _>(format!(
62
- "const $ = {}; Object.assign(globalThis, $); $.unsafe = (s) => s; globalThis.$ = $",
50
+ "const $ = {}; Object.assign(globalThis, $); \
51
+ globalThis.RawHtml = class {{ constructor(html) {{ this.html = html == null ? '' : String(html) }} }}; \
52
+ $.unsafe = (html) => new RawHtml(html); globalThis.$ = $",
63
53
  state_str
64
54
  ))
65
55
  .map_err(|e| format!("Failed to set up state in QuickJS: {:?}", e))?;
66
56
  Ok(())
67
57
  })?;
68
58
 
69
- let constant_texts = constants
70
- .iter()
71
- .map(|(key, value)| (key.clone(), primitive_to_text(value)))
72
- .collect();
73
-
74
59
  Ok(Self {
75
60
  state,
76
61
  binding_regex: Regex::new(r"@\[((?:[^\[\]]|\[[^\]]*\])+)\]").unwrap(),
77
62
  _runtime: runtime,
78
63
  context,
79
64
  components_as_is,
80
- constant_texts,
81
65
  selective: false,
82
66
  })
83
67
  }
@@ -107,12 +91,40 @@ impl<'a> ValueStamper<'a> {
107
91
  // Then, render conditionals (resolve if/else branches)
108
92
  let html = self.render_conditionals(html)?;
109
93
 
94
+ // Stamp bindings only in regions that are NOT in skip_regions
95
+ let (mut result, unresolved) = self.stamp_bindings_tracked(&html, &self.skip_regions(&html));
96
+
97
+ // Clean up attributes that still contain unresolved markers. Selective
98
+ // mode keeps them: an unresolved marker there is runtime-owned state
99
+ // (the outlet's `src="@[page.src]"`), not an absent iteration property.
100
+ if !self.selective {
101
+ result = self.cleanup_unresolved_attributes(result, &unresolved);
102
+ }
103
+
104
+ // Restore vibe-dehydrate content from placeholders
105
+ for (i, content) in dehydrate_placeholders.iter().enumerate() {
106
+ let placeholder = format!("<!--VIBE_DEHYDRATE_PLACEHOLDER_{}-->", i);
107
+ result = result.replace(&placeholder, content);
108
+ }
109
+
110
+ // Finally, clean up boolean-like attributes with falsy values
111
+ // When components_as_is is true, skip cleanup inside component elements.
112
+ // Regions are measured again on the stamped HTML: stamping changes
113
+ // lengths, so the pre-stamp offsets point at the wrong attributes.
114
+ let skip_regions = self.skip_regions(&result);
115
+ Ok(self.cleanup_boolean_attributes(result, &skip_regions))
116
+ }
117
+
118
+ /// Byte ranges of the regions stamping and cleanup leave untouched:
119
+ /// non-reactive elements, vibe-dehydrate templates and, when
120
+ /// components_as_is is true, component elements.
121
+ fn skip_regions(&self, html: &str) -> Vec<(usize, usize)> {
110
122
  // Find all NON_REACTIVE_ELEMENT regions to skip
111
123
  let mut skip_regions: Vec<(usize, usize)> = Vec::new();
112
124
 
113
125
  for element_type in NON_REACTIVE_ELEMENTS {
114
126
  let regex = Regex::new(&format!(r"(?s)<{}[^>]*>.*?</{}>", element_type, element_type)).unwrap();
115
- for mat in regex.find_iter(&html) {
127
+ for mat in regex.find_iter(html) {
116
128
  skip_regions.push((mat.start(), mat.end()));
117
129
  }
118
130
  }
@@ -120,7 +132,7 @@ impl<'a> ValueStamper<'a> {
120
132
  // Skip elements with vibe-dehydrate attribute (content should not be processed)
121
133
  // This is a secondary protection in case placeholders aren't used
122
134
  let dehydrate_regex = Regex::new(&format!(r"(?s)<template[^>]*\s{}[^>]*>.*?</template>", DEHYDRATE_ATTR)).unwrap();
123
- for mat in dehydrate_regex.find_iter(&html) {
135
+ for mat in dehydrate_regex.find_iter(html) {
124
136
  skip_regions.push((mat.start(), mat.end()));
125
137
  }
126
138
 
@@ -129,55 +141,40 @@ impl<'a> ValueStamper<'a> {
129
141
  if self.components_as_is {
130
142
  // Match <component ...>...</component>
131
143
  let component_regex = Regex::new(r"(?s)<component[^>]*>.*?</component>").unwrap();
132
- for mat in component_regex.find_iter(&html) {
144
+ for mat in component_regex.find_iter(html) {
133
145
  skip_regions.push((mat.start(), mat.end()));
134
146
  }
135
147
 
136
148
  // Match <div class="component" ...>...</div>
137
149
  let div_component_regex = Regex::new(r#"(?s)<div\s+class="component"[^>]*>.*?</div>"#).unwrap();
138
- for mat in div_component_regex.find_iter(&html) {
150
+ for mat in div_component_regex.find_iter(html) {
139
151
  skip_regions.push((mat.start(), mat.end()));
140
152
  }
141
153
  }
142
154
 
143
155
  // Sort and merge overlapping regions
144
156
  skip_regions.sort_by_key(|r| r.0);
145
-
146
- // Stamp bindings only in regions that are NOT in skip_regions
147
- let (mut result, unresolved) = self.stamp_bindings_tracked(&html, &skip_regions);
148
-
149
- // Clean up attributes that still contain unresolved markers. Selective
150
- // mode keeps them: an unresolved marker there is runtime-owned state
151
- // (the outlet's `src="@[page.src]"`), not an absent iteration property.
152
- if !self.selective {
153
- result = self.cleanup_unresolved_attributes(result, &unresolved);
154
- }
155
-
156
- // Restore vibe-dehydrate content from placeholders
157
- for (i, content) in dehydrate_placeholders.iter().enumerate() {
158
- let placeholder = format!("<!--VIBE_DEHYDRATE_PLACEHOLDER_{}-->", i);
159
- result = result.replace(&placeholder, content);
160
- }
161
-
162
- // Finally, clean up boolean-like attributes with falsy values
163
- // When components_as_is is true, skip cleanup inside component elements
164
- Ok(self.cleanup_boolean_attributes(result, &skip_regions))
157
+ skip_regions
165
158
  }
166
159
 
167
160
  /// Evaluate a JavaScript expression with the current state
168
161
  fn eval_expression(&self, expr: &str) -> Option<String> {
169
- self.eval_with_state(expr, self.state)
162
+ self.eval_with_state(expr, &self.state)
170
163
  }
171
164
 
172
165
  /// Evaluate a JavaScript expression with custom state (for nested iterations)
166
+ /// into the HTML it stamps: escaped text, or the raw markup of `$.unsafe`.
173
167
  fn eval_with_state(&self, expr: &str, _state: &Value) -> Option<String> {
174
168
  // State is already set up in the context during new(), just evaluate the expression
175
169
  self.context.with(|ctx| -> Option<String> {
176
- // Evaluate expression and convert to string
177
170
  let result: rquickjs::Value = ctx.eval(expr).ok()?;
171
+ let raw_html: rquickjs::Value = ctx.globals().get("RawHtml").ok()?;
172
+ if let Some(raw) = result.as_object().filter(|o| o.is_instance_of(&raw_html)) {
173
+ return raw.get("html").ok();
174
+ }
178
175
 
179
176
  // Convert result to string
180
- if result.is_string() {
177
+ let text = if result.is_string() {
181
178
  result.as_string().and_then(|s| s.to_string().ok())
182
179
  } else if result.is_number() {
183
180
  result.as_number().map(|n| n.to_string())
@@ -191,7 +188,8 @@ impl<'a> ValueStamper<'a> {
191
188
  // For objects/arrays, convert to JSON
192
189
  ctx.json_stringify(result).ok()
193
190
  .and_then(|v| v.and_then(|s| s.to_string().ok()))
194
- }
191
+ };
192
+ text.map(|text| escape_html(&text))
195
193
  })
196
194
  }
197
195
 
@@ -301,7 +299,7 @@ impl<'a> ValueStamper<'a> {
301
299
 
302
300
  context.with(|ctx| -> Option<Vec<Value>> {
303
301
  // Set state properties in global scope
304
- let state_str = serde_json::to_string(self.state).ok()?;
302
+ let state_str = serde_json::to_string(&self.state).ok()?;
305
303
  ctx.eval::<(), _>(format!("const $ = {}; Object.assign(globalThis, $)", state_str)).ok()?;
306
304
 
307
305
  // Evaluate path to get array
@@ -683,14 +681,10 @@ impl<'a> ValueStamper<'a> {
683
681
  result.push_str(binding_match.as_str());
684
682
  } else {
685
683
  let caps = self.binding_regex.captures(binding_match.as_str()).unwrap();
686
- let expr = &caps[1];
687
- // A bare reference to a proven-constant global key (`@[version]`)
688
- // stamps its build-time value. Any compound expression falls
689
- // through to normal evaluation, where dynamic globals stay absent
690
- // and the binding is left live for the runtime.
691
- if let Some(text) = self.constant_texts.get(expr.trim()) {
692
- result.push_str(text);
693
- } else if let Some(stamped) = self.eval_expression(expr) {
684
+ // The HTML is serialized, so `=>` in a text binding reads `=&gt;`
685
+ // and `"` in an attribute binding reads `&quot;` — decode back to
686
+ // the JS the runtime evaluates.
687
+ if let Some(stamped) = self.eval_expression(&decode_entities(&caps[1])) {
694
688
  result.push_str(&stamped);
695
689
  } else {
696
690
  unresolved.push(result.len());
@@ -791,6 +785,10 @@ impl<'a> ValueStamper<'a> {
791
785
  }
792
786
  }
793
787
 
788
+ fn escape_html(text: &str) -> String {
789
+ text.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
790
+ }
791
+
794
792
  #[cfg(test)]
795
793
  mod tests {
796
794
  use super::*;
@@ -888,6 +886,16 @@ mod tests {
888
886
  assert_eq!(result, "<p><em>x</em></p>");
889
887
  }
890
888
 
889
+ #[test]
890
+ fn stamp_escapes_quotes_and_angle_brackets() {
891
+ let state = json!({ "snippet": r#"<component src="/card.html"></component> & more"# });
892
+ let stamper = ValueStamper::new(&state, false).unwrap();
893
+ let html = String::from(r#"<copy-button data-value="@[snippet]">@[snippet]</copy-button>"#);
894
+ let result = stamper.stamp_html(html).unwrap();
895
+ let escaped = "&lt;component src=&quot;/card.html&quot;&gt;&lt;/component&gt; &amp; more";
896
+ assert_eq!(result, format!(r#"<copy-button data-value="{escaped}">{escaped}</copy-button>"#));
897
+ }
898
+
891
899
  #[test]
892
900
  fn stamp_attribute_binding() {
893
901
  let state = json!({ "firstName": "John" });
@@ -1051,5 +1059,41 @@ mod tests {
1051
1059
  let result = stamper.stamp_html("<x>@[version + suffix]</x>".to_string()).unwrap();
1052
1060
  assert!(!result.contains("0.1.5"), "constant leaked into compound: {result}");
1053
1061
  }
1062
+
1063
+ #[test]
1064
+ fn compound_expression_over_constants_is_stamped_from_serialized_html() {
1065
+ // An inlined component's `@[name.split…]` with `name="@[team]"` arrives
1066
+ // as `@[(team).split…]`, its `=>` serialized to `=&gt;`.
1067
+ let state = json!({});
1068
+ let consts = constants(&[("team", json!("Ada Lovelace"))]);
1069
+ let stamper = ValueStamper::with_constants(&state, false, &consts).unwrap();
1070
+ let result = stamper
1071
+ .stamp_html("<avatar-circle>@[(team).split(' ').map(part =&gt; part[0]).join('')]</avatar-circle>".to_string())
1072
+ .unwrap();
1073
+ assert_eq!(result, "<avatar-circle>AL</avatar-circle>");
1074
+ }
1075
+
1076
+ #[test]
1077
+ fn cleanup_measures_skip_regions_after_a_binding_shrinks() {
1078
+ // The avatar's binding stamps 52 bytes down to `AL`, pulling the hint
1079
+ // box back into where the `<pre>` sat before stamping — cleanup must
1080
+ // still see its attributes as outside the `<pre>`.
1081
+ let state = json!({ "gone": false, "secondary": "" });
1082
+ let consts = constants(&[("team", json!("Ada Lovelace"))]);
1083
+ let stamper = ValueStamper::with_constants(&state, false, &consts).unwrap();
1084
+ let result = stamper
1085
+ .stamp_html(
1086
+ "<avatar-circle>@[(team).split(' ').map(part =&gt; part[0]).join('')]</avatar-circle>\
1087
+ <pre>npm install @ape-egg/vibe --save-dev</pre>\
1088
+ <hint-box gone=\"@[gone]\"><button secondary=\"@[secondary]\">Go</button></hint-box>"
1089
+ .to_string(),
1090
+ )
1091
+ .unwrap();
1092
+ assert_eq!(
1093
+ result,
1094
+ "<avatar-circle>AL</avatar-circle><pre>npm install @ape-egg/vibe --save-dev</pre>\
1095
+ <hint-box><button secondary>Go</button></hint-box>"
1096
+ );
1097
+ }
1054
1098
  }
1055
1099
 
@@ -471,6 +471,10 @@ pub fn watch(config: Config, verbose: bool, launcher: Option<u32>) -> std::resul
471
471
  }
472
472
  }
473
473
 
474
+ if let Err(e) = compiler.remove_stale_output() {
475
+ eprintln!("\n{}: Removing stale output failed: {}", "Warning".yellow(), e);
476
+ }
477
+
474
478
  // Show success headline
475
479
  println!("\n{}", "Initial compilation complete!".green().bold());
476
480
  println!();
@@ -525,8 +529,8 @@ pub fn watch(config: Config, verbose: bool, launcher: Option<u32>) -> std::resul
525
529
  if let Some(nm_time) = stats.node_modules_time_ms {
526
530
  if stats.node_modules_copied_as_is {
527
531
  println!("* copied node_modules in {:.0}ms", nm_time);
528
- } else if let Some(ref pkg_manager) = stats.package_manager {
529
- println!("* {} install in {:.0}ms", pkg_manager, nm_time);
532
+ } else {
533
+ println!("* copied dependencies in {:.0}ms", nm_time);
530
534
  }
531
535
  }
532
536
 
@@ -879,7 +883,6 @@ pub fn watch(config: Config, verbose: bool, launcher: Option<u32>) -> std::resul
879
883
  components_time_ms: 0.0,
880
884
  validation_time_ms: None,
881
885
  node_modules_time_ms: None,
882
- package_manager: None,
883
886
  node_modules_copied_as_is: false,
884
887
  components_as_is: config.components_as_is,
885
888
  };
package/src/config.rs CHANGED
@@ -38,6 +38,8 @@ pub struct VibeCompilerConfig {
38
38
  pub skip_files: Vec<String>,
39
39
  #[serde(default)]
40
40
  pub node_modules_as_is: bool,
41
+ #[serde(default = "default_modules")]
42
+ pub modules: String,
41
43
  #[serde(default)]
42
44
  pub components_as_is: bool,
43
45
  #[serde(default)]
@@ -73,6 +75,7 @@ fn default_output() -> String { "./compiled".to_string() }
73
75
  fn default_components() -> String { "components".to_string() }
74
76
  fn default_pages() -> String { "pages".to_string() }
75
77
  fn default_assets() -> String { "assets".to_string() }
78
+ fn default_modules() -> String { "node_modules".to_string() }
76
79
 
77
80
  /// Get list of standard HTML5 elements + "component"
78
81
  /// Component names matching these are reserved and not allowed
@@ -118,6 +121,7 @@ impl Default for VibeCompilerConfig {
118
121
  reserved_elements: vec![],
119
122
  skip_files: vec![],
120
123
  node_modules_as_is: false,
124
+ modules: default_modules(),
121
125
  components_as_is: false,
122
126
  runtime_as_is: false,
123
127
  iterations_as_is: false,
@@ -145,6 +149,7 @@ pub struct Config {
145
149
  pub reserved_elements: Vec<String>,
146
150
  pub skip_files: Vec<String>,
147
151
  pub node_modules_as_is: bool,
152
+ pub modules: String,
148
153
  pub components_as_is: bool,
149
154
  pub runtime_as_is: bool,
150
155
  pub iterations_as_is: bool,
@@ -208,6 +213,7 @@ impl Config {
208
213
  reserved_elements,
209
214
  skip_files,
210
215
  node_modules_as_is: config.node_modules_as_is,
216
+ modules: config.modules,
211
217
  components_as_is: config.components_as_is,
212
218
  runtime_as_is: config.runtime_as_is,
213
219
  iterations_as_is: config.iterations_as_is,
@@ -219,6 +225,10 @@ impl Config {
219
225
  }
220
226
  }
221
227
 
228
+ pub fn modules_path(&self) -> PathBuf {
229
+ self.output.join(&self.modules)
230
+ }
231
+
222
232
  pub fn components_path(&self) -> PathBuf {
223
233
  self.source.join(&self.components)
224
234
  }
package/src/main.rs CHANGED
@@ -242,6 +242,7 @@ fn main() {
242
242
  println!(" {}: {}", format!("{:<16}", "elementsAsIs").cyan(), format_bool_with_flag(config.elements_as_is, overrides.elements_as_is, original_elements_as_is));
243
243
  println!(" {}: {}", format!("{:<16}", "iterationsAsIs").cyan(), format_bool_with_flag(config.iterations_as_is, overrides.iterations_as_is, original_iterations_as_is));
244
244
  println!(" {}: {}", format!("{:<16}", "minify").cyan(), format_bool_with_flag(config.minify, overrides.minify, original_minify));
245
+ println!(" {}: {}", format!("{:<16}", "modules").cyan(), format_value_no_flag(&config.modules));
245
246
  println!(" {}: {}", format!("{:<16}", "nodeModulesAsIs").cyan(), format_bool_with_flag(config.node_modules_as_is, overrides.node_modules_as_is, original_node_modules_as_is));
246
247
  println!(" {}: {}", format!("{:<16}", "output").cyan(), format_value_no_flag(&config._output_str));
247
248
  println!(" {}: {}", format!("{:<16}", "pages").cyan(), format_value_no_flag(&config.pages));
@@ -291,6 +292,10 @@ fn main() {
291
292
  }
292
293
  }
293
294
 
295
+ if let Err(e) = compiler.remove_stale_output() {
296
+ eprintln!("\n{}: Removing stale output failed: {}", "Warning".yellow(), e);
297
+ }
298
+
294
299
  // Show success headline
295
300
  println!("\n{}", format!("Compilation successful! (v{}) ✅", env!("CARGO_PKG_VERSION")).green().bold());
296
301
  println!();
@@ -345,8 +350,8 @@ fn main() {
345
350
  if let Some(nm_time) = stats.node_modules_time_ms {
346
351
  if stats.node_modules_copied_as_is {
347
352
  println!("* copied node_modules in {:.0}ms", nm_time);
348
- } else if let Some(ref pkg_manager) = stats.package_manager {
349
- println!("* {} install in {:.0}ms", pkg_manager, nm_time);
353
+ } else {
354
+ println!("* copied dependencies in {:.0}ms", nm_time);
350
355
  }
351
356
  }
352
357
 
@@ -681,7 +681,7 @@ fn escape_attr_value(value: &str) -> String {
681
681
  .replace('\'', "&#39;")
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("&quot;", "\"")
687
687
  .replace("&#34;", "\"")