@ape-egg/vibe 1.1.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,222 @@
1
+ use regex::{Regex, Captures};
2
+ use serde_json::Value;
3
+
4
+ pub struct ValueStamper<'a> {
5
+ state: &'a Value,
6
+ binding_regex: Regex,
7
+ iteration_regex: Regex,
8
+ }
9
+
10
+ impl<'a> ValueStamper<'a> {
11
+ pub fn new(state: &'a Value) -> Self {
12
+ Self {
13
+ state,
14
+ binding_regex: Regex::new(r"@\[((?:[^\[\]]|\[[^\]]*\])+)\]").unwrap(),
15
+ iteration_regex: Regex::new(r"(?s)<!--\s*each\s+(\w+(?:\.\w+)*)\s+as\s+(\w+)(?:\s*,\s*(\w+))?\s*-->(.*?)<!--\s*/each\s*-->").unwrap(),
16
+ }
17
+ }
18
+
19
+ pub fn stamp_html(&self, html: String) -> Result<String, String> {
20
+ // First, render iterations (expands templates into multiple instances)
21
+ let html = self.render_iterations(html)?;
22
+
23
+ // Then stamp all bindings (including those in rendered iterations)
24
+ Ok(self.binding_regex.replace_all(&html, |caps: &Captures| {
25
+ let path = &caps[1];
26
+ self.resolve_state_path(path)
27
+ .unwrap_or_else(|| caps[0].to_string()) // Keep marker if can't resolve
28
+ }).to_string())
29
+ }
30
+
31
+ fn render_iterations(&self, html: String) -> Result<String, String> {
32
+ let result = self.iteration_regex.replace_all(&html, |caps: &Captures| {
33
+ let array_path = &caps[1];
34
+ let item_alias = &caps[2];
35
+ let index_alias = caps.get(3).map(|m| m.as_str()).unwrap_or("index");
36
+ let template = &caps[4];
37
+
38
+ // Get array from state
39
+ let array = match self.resolve_array_path(array_path) {
40
+ Some(arr) => arr,
41
+ None => {
42
+ // Array not found, keep iteration block as-is
43
+ return caps[0].to_string();
44
+ }
45
+ };
46
+
47
+ // Render each item
48
+ let mut rendered_items = Vec::new();
49
+ for (idx, item) in array.iter().enumerate() {
50
+ let mut item_html = template.to_string();
51
+
52
+ // Replace @[item] and @[index] in template
53
+ item_html = item_html.replace(
54
+ &format!("@[{}]", item_alias),
55
+ &self.value_to_string(item)
56
+ );
57
+ item_html = item_html.replace(
58
+ &format!("@[{}]", index_alias),
59
+ &idx.to_string()
60
+ );
61
+
62
+ rendered_items.push(item_html);
63
+ }
64
+
65
+ // Reconstruct with comments (preserve index alias if it was in original)
66
+ let index_part = if caps.get(3).is_some() {
67
+ format!(", {}", index_alias)
68
+ } else {
69
+ String::new()
70
+ };
71
+
72
+ format!(
73
+ "<!-- each {} as {}{} -->{}<!-- /each -->",
74
+ array_path,
75
+ item_alias,
76
+ index_part,
77
+ rendered_items.join("")
78
+ )
79
+ });
80
+
81
+ Ok(result.to_string())
82
+ }
83
+
84
+ fn resolve_array_path(&self, path: &str) -> Option<&Vec<Value>> {
85
+ let parts: Vec<&str> = path.split('.').collect();
86
+ let mut current = self.state;
87
+
88
+ for part in parts {
89
+ current = current.get(part)?;
90
+ }
91
+
92
+ current.as_array()
93
+ }
94
+
95
+ fn resolve_state_path(&self, path: &str) -> Option<String> {
96
+ // Navigate state by dot path: "user.name" -> state["user"]["name"]
97
+ let parts: Vec<&str> = path.split('.').collect();
98
+ let mut current = self.state;
99
+
100
+ for part in parts {
101
+ // Handle array access: items[0]
102
+ if let Some(bracket_pos) = part.find('[') {
103
+ let array_name = &part[..bracket_pos];
104
+ let index_str = &part[bracket_pos+1..part.len()-1];
105
+ let index: usize = index_str.parse().ok()?;
106
+
107
+ current = current.get(array_name)?.get(index)?;
108
+ } else {
109
+ current = current.get(part)?;
110
+ }
111
+ }
112
+
113
+ Some(self.value_to_string(current))
114
+ }
115
+
116
+ fn value_to_string(&self, val: &Value) -> String {
117
+ match val {
118
+ Value::String(s) => s.clone(),
119
+ Value::Number(n) => n.to_string(),
120
+ Value::Bool(b) => b.to_string(),
121
+ Value::Null => String::new(),
122
+ Value::Array(_) | Value::Object(_) => val.to_string(),
123
+ }
124
+ }
125
+ }
126
+
127
+ #[cfg(test)]
128
+ mod tests {
129
+ use super::*;
130
+ use serde_json::json;
131
+
132
+ #[test]
133
+ fn stamp_simple_binding() {
134
+ let state = json!({ "name": "World" });
135
+ let stamper = ValueStamper::new(&state);
136
+ let html = String::from("<div>Hello @[name]</div>");
137
+ let result = stamper.stamp_html(html).unwrap();
138
+ assert_eq!(result, "<div>Hello World</div>");
139
+ }
140
+
141
+ #[test]
142
+ fn stamp_multiple_bindings() {
143
+ let state = json!({ "firstName": "John", "lastName": "Doe" });
144
+ let stamper = ValueStamper::new(&state);
145
+ let html = String::from("<div>@[firstName] @[lastName]</div>");
146
+ let result = stamper.stamp_html(html).unwrap();
147
+ assert_eq!(result, "<div>John Doe</div>");
148
+ }
149
+
150
+ #[test]
151
+ fn stamp_nested_binding() {
152
+ let state = json!({ "user": { "name": "John" } });
153
+ let stamper = ValueStamper::new(&state);
154
+ let html = String::from("<div>Hello @[user.name]</div>");
155
+ let result = stamper.stamp_html(html).unwrap();
156
+ assert_eq!(result, "<div>Hello John</div>");
157
+ }
158
+
159
+ #[test]
160
+ fn stamp_array_binding() {
161
+ let state = json!({ "items": ["first", "second", "third"] });
162
+ let stamper = ValueStamper::new(&state);
163
+ let html = String::from("<div>@[items[0]]</div>");
164
+ let result = stamper.stamp_html(html).unwrap();
165
+ assert_eq!(result, "<div>first</div>");
166
+ }
167
+
168
+ #[test]
169
+ fn stamp_number_binding() {
170
+ let state = json!({ "count": 42 });
171
+ let stamper = ValueStamper::new(&state);
172
+ let html = String::from("<div>Count: @[count]</div>");
173
+ let result = stamper.stamp_html(html).unwrap();
174
+ assert_eq!(result, "<div>Count: 42</div>");
175
+ }
176
+
177
+ #[test]
178
+ fn stamp_boolean_binding() {
179
+ let state = json!({ "isActive": true });
180
+ let stamper = ValueStamper::new(&state);
181
+ let html = String::from("<div>Active: @[isActive]</div>");
182
+ let result = stamper.stamp_html(html).unwrap();
183
+ assert_eq!(result, "<div>Active: true</div>");
184
+ }
185
+
186
+ #[test]
187
+ fn stamp_missing_binding() {
188
+ let state = json!({ "name": "World" });
189
+ let stamper = ValueStamper::new(&state);
190
+ let html = String::from("<div>Hello @[missing]</div>");
191
+ let result = stamper.stamp_html(html).unwrap();
192
+ // Should keep the binding marker if value doesn't exist
193
+ assert_eq!(result, "<div>Hello @[missing]</div>");
194
+ }
195
+
196
+ #[test]
197
+ fn stamp_attribute_binding() {
198
+ let state = json!({ "firstName": "John" });
199
+ let stamper = ValueStamper::new(&state);
200
+ let html = String::from(r#"<input value="@[firstName]">"#);
201
+ let result = stamper.stamp_html(html).unwrap();
202
+ assert_eq!(result, r#"<input value="John">"#);
203
+ }
204
+
205
+ #[test]
206
+ fn render_simple_iteration() {
207
+ let state = json!({ "items": [1, 2, 3] });
208
+ let stamper = ValueStamper::new(&state);
209
+ let html = String::from(r#"<!-- each items as item --><div>@[item]</div><!-- /each -->"#);
210
+ let result = stamper.stamp_html(html).unwrap();
211
+ assert_eq!(result, r#"<!-- each items as item --><div>1</div><div>2</div><div>3</div><!-- /each -->"#);
212
+ }
213
+
214
+ #[test]
215
+ fn render_iteration_with_index() {
216
+ let state = json!({ "items": ["a", "b", "c"] });
217
+ let stamper = ValueStamper::new(&state);
218
+ let html = String::from(r#"<!-- each items as item, idx --><span>[@[idx]] @[item]</span><!-- /each -->"#);
219
+ let result = stamper.stamp_html(html).unwrap();
220
+ assert_eq!(result, r#"<!-- each items as item, idx --><span>[0] a</span><span>[1] b</span><span>[2] c</span><!-- /each -->"#);
221
+ }
222
+ }
@@ -31,8 +31,6 @@ pub struct VibeCompilerConfig {
31
31
  #[serde(default)]
32
32
  pub accessibility: bool,
33
33
  #[serde(default)]
34
- pub manifest: bool,
35
- #[serde(default)]
36
34
  pub validate: bool,
37
35
  #[serde(default)]
38
36
  pub source_maps: bool,
@@ -61,7 +59,6 @@ impl Default for VibeCompilerConfig {
61
59
  root: None,
62
60
  minify: false,
63
61
  accessibility: false,
64
- manifest: false,
65
62
  validate: false,
66
63
  source_maps: false,
67
64
  exclude_tags: vec![],
@@ -83,7 +80,6 @@ pub struct Config {
83
80
  pub _root: Option<String>,
84
81
  pub minify: bool,
85
82
  pub accessibility: bool,
86
- pub manifest: bool,
87
83
  pub validate: bool,
88
84
  pub source_maps: bool,
89
85
  pub exclude_tags: Vec<String>,
@@ -124,7 +120,6 @@ impl Config {
124
120
  _root: config.root,
125
121
  minify: config.minify,
126
122
  accessibility: config.accessibility,
127
- manifest: config.manifest,
128
123
  validate: config.validate,
129
124
  source_maps: config.source_maps,
130
125
  exclude_tags: config.exclude_tags,
@@ -5,7 +5,6 @@ mod compiler;
5
5
  use clap::Parser as ClapParser;
6
6
  use colored::Colorize;
7
7
  use std::path::PathBuf;
8
- use std::time::Instant;
9
8
 
10
9
  use config::Config;
11
10
  use compiler::compile::CompileError;
@@ -16,11 +15,11 @@ use compiler::Compiler;
16
15
  struct ConfigOverrides {
17
16
  minify: bool,
18
17
  accessibility: bool,
19
- manifest: bool,
20
18
  validate: bool,
21
19
  source_maps: bool,
22
20
  node_modules_as_is: bool,
23
21
  components_as_is: bool,
22
+ runtime_as_is: bool,
24
23
  }
25
24
 
26
25
  /// Vibe Compiler - Compiles Vibe source files into optimized output
@@ -50,10 +49,6 @@ struct Args {
50
49
  #[arg(long)]
51
50
  accessibility: bool,
52
51
 
53
- /// Generate hydration manifest
54
- #[arg(long, name = "create-manifest")]
55
- create_manifest: bool,
56
-
57
52
  /// Generate source maps
58
53
  #[arg(long, name = "source-maps")]
59
54
  source_maps: bool,
@@ -81,11 +76,14 @@ struct Args {
81
76
  /// Keep components directory and <component> tags as-is (don't inline components)
82
77
  #[arg(long, name = "components-as-is")]
83
78
  components_as_is: bool,
79
+
80
+ /// Skip manifest generation - output will use runtime-only mode (no FOUC prevention)
81
+ #[arg(long, name = "runtime-as-is")]
82
+ runtime_as_is: bool,
84
83
  }
85
84
 
86
85
  fn main() {
87
86
  let args = Args::parse();
88
- let start = Instant::now();
89
87
 
90
88
  // Determine working directory
91
89
  let working_dir = args.cwd
@@ -117,7 +115,6 @@ fn main() {
117
115
  // Store original config values before applying flag overrides
118
116
  let original_minify = config.minify;
119
117
  let original_accessibility = config.accessibility;
120
- let original_manifest = config.manifest;
121
118
  let original_validate = config.validate;
122
119
  let original_source_maps = config.source_maps;
123
120
  let original_node_modules_as_is = config.node_modules_as_is;
@@ -134,10 +131,6 @@ fn main() {
134
131
  overrides.accessibility = !original_accessibility;
135
132
  config.accessibility = true;
136
133
  }
137
- if args.create_manifest {
138
- overrides.manifest = !original_manifest;
139
- config.manifest = true;
140
- }
141
134
  if args.validate {
142
135
  overrides.validate = !original_validate;
143
136
  config.validate = true;
@@ -190,12 +183,12 @@ fn main() {
190
183
  println!(" {}: {}", format!("{:<18}", "components").cyan(), format_value_no_flag(&config.components));
191
184
  println!(" {}: {}", format!("{:<18}", "components-as-is").cyan(), format_bool_with_flag(config.components_as_is, overrides.components_as_is, original_components_as_is));
192
185
  println!(" {}: {}", format!("{:<18}", "exclude-tags").cyan(), format_value_no_flag(format!("{:?}", config.exclude_tags)));
193
- println!(" {}: {}", format!("{:<18}", "manifest").cyan(), format_bool_with_flag(config.manifest, overrides.manifest, original_manifest));
194
186
  println!(" {}: {}", format!("{:<18}", "minify").cyan(), format_bool_with_flag(config.minify, overrides.minify, original_minify));
195
187
  println!(" {}: {}", format!("{:<18}", "node-modules-as-is").cyan(), format_bool_with_flag(config.node_modules_as_is, overrides.node_modules_as_is, original_node_modules_as_is));
196
188
  println!(" {}: {}", format!("{:<18}", "output").cyan(), format_value_no_flag(&config._output_str));
197
189
  println!(" {}: {}", format!("{:<18}", "pages").cyan(), format_value_no_flag(&config.pages));
198
190
  println!(" {}: {}", format!("{:<18}", "root").cyan(), format_value_no_flag(config._root.as_ref().map(|s| s.as_str()).unwrap_or("null")));
191
+ println!(" {}: {}", format!("{:<18}", "runtime-as-is").cyan(), if overrides.runtime_as_is { "true (flag)".green().to_string() } else { "false".to_string() });
199
192
  println!(" {}: {}", format!("{:<18}", "source").cyan(), format_value_no_flag(&config._source_str));
200
193
  println!(" {}: {}", format!("{:<18}", "source-maps").cyan(), format_bool_with_flag(config.source_maps, overrides.source_maps, original_source_maps));
201
194
  println!(" {}: {}", format!("{:<18}", "validate").cyan(), format_bool_with_flag(config.validate, overrides.validate, original_validate));
@@ -206,13 +199,28 @@ fn main() {
206
199
 
207
200
  match compiler.compile() {
208
201
  Ok(stats) => {
209
- let duration = start.elapsed();
202
+ // Generate manifests BEFORE showing success (unless --runtime-as-is flag is passed)
203
+ let mut manifest_time_ms = 0.0;
204
+ let mut manifest_stats_result = None;
205
+ if !args.runtime_as_is {
206
+ match compiler.generate_manifests() {
207
+ Ok(manifest_stats) => {
208
+ manifest_time_ms = manifest_stats.total_time_ms;
209
+ manifest_stats_result = Some(manifest_stats);
210
+ }
211
+ Err(e) => {
212
+ eprintln!("\n{}: Manifest generation failed: {}", "Warning".yellow(), e);
213
+ eprintln!("Compilation succeeded but manifests were not generated.");
214
+ eprintln!("Use --runtime-as-is to skip manifest generation.");
215
+ }
216
+ }
217
+ }
210
218
 
211
219
  // Show success headline
212
220
  println!("\n{}", "Compilation successful! ✅".green().bold());
213
221
  println!();
214
222
 
215
- // Show individual phase timings (validation first, then components, HTML, copied)
223
+ // Show individual phase timings (validation first, then components, HTML, manifests, copied)
216
224
 
217
225
  // Show validation time if it happened
218
226
  if let Some(validation_time) = stats.validation_time_ms {
@@ -240,6 +248,16 @@ fn main() {
240
248
  );
241
249
  }
242
250
 
251
+ // Show manifest stats between HTML and Copied files
252
+ if let Some(manifest_stats) = manifest_stats_result {
253
+ println!("* Generated manifests ({} file{}, {} skipped) in {:.0}ms",
254
+ manifest_stats.pages_processed,
255
+ if manifest_stats.pages_processed == 1 { "" } else { "s" },
256
+ manifest_stats.pages_skipped,
257
+ manifest_stats.total_time_ms
258
+ );
259
+ }
260
+
243
261
  if stats.files_copied > 0 {
244
262
  println!("* Copied files ({} file{}) in {:.0}ms",
245
263
  stats.files_copied,
@@ -257,7 +275,18 @@ fn main() {
257
275
  }
258
276
  }
259
277
 
260
- println!("\n{} in {:.0}ms", "Compiled".green(), duration.as_secs_f64() * 1000.0);
278
+ // Calculate total duration as sum of all individual operations
279
+ let total_duration_ms = stats.validation_time_ms.unwrap_or(0.0)
280
+ + stats.components_time_ms
281
+ + stats.compile_time_ms
282
+ + stats.copy_time_ms
283
+ + stats.node_modules_time_ms.unwrap_or(0.0)
284
+ + manifest_time_ms;
285
+
286
+ println!("\n{} in {:.0}ms", "Compiled".green(), total_duration_ms);
287
+
288
+ // Exit explicitly to kill background server thread
289
+ std::process::exit(0);
261
290
  }
262
291
  Err(e) => {
263
292
  match e {
@@ -277,8 +306,4 @@ fn main() {
277
306
  std::process::exit(1);
278
307
  }
279
308
  }
280
-
281
- if args.watch {
282
- println!("{}", "Watch mode not yet implemented".yellow());
283
- }
284
309
  }
@@ -103,6 +103,10 @@ impl HtmlParser {
103
103
  components_dir: &str,
104
104
  external_cache: &HashMap<String, String>,
105
105
  ) -> String {
106
+ // Extract and preserve DOCTYPE declaration if present
107
+ let doctype_re = regex::Regex::new(r"(?i)^\s*<!DOCTYPE[^>]*>\s*").unwrap();
108
+ let doctype = doctype_re.find(content).map(|m| m.as_str().to_string());
109
+
106
110
  let mut result = content.to_string();
107
111
 
108
112
  // Step 1: Transform custom tags matching element files to <component> tags
@@ -118,6 +122,14 @@ impl HtmlParser {
118
122
  result = transform_custom_tags_to_divs(&result, exclude_tags);
119
123
  }
120
124
 
125
+ // Step 4: Restore DOCTYPE if it was present
126
+ if let Some(dt) = doctype {
127
+ // Remove any existing DOCTYPE that might have been left behind
128
+ result = doctype_re.replace(&result, "").to_string();
129
+ // Prepend the original DOCTYPE
130
+ result = format!("{}{}", dt, result);
131
+ }
132
+
121
133
  result
122
134
  }
123
135
 
@@ -235,14 +247,16 @@ impl HtmlParser {
235
247
  replacement = replacement.replace(&prop_binding, prop_value);
236
248
  }
237
249
 
238
- // Replace <slot></slot> with slot content if present
239
- if let Some(ref slot) = slot_content {
240
- if !slot.trim().is_empty() {
241
- replacement = replacement.replace("<slot></slot>", slot);
242
- replacement = replacement.replace("<slot/>", slot);
243
- replacement = replacement.replace("<slot />", slot);
244
- }
245
- }
250
+ // Replace <slot> tags with content, or remove if empty/missing
251
+ let slot_replacement = slot_content
252
+ .as_ref()
253
+ .filter(|s| !s.trim().is_empty())
254
+ .map(|s| s.as_str())
255
+ .unwrap_or("");
256
+
257
+ replacement = replacement.replace("<slot></slot>", slot_replacement);
258
+ replacement = replacement.replace("<slot/>", slot_replacement);
259
+ replacement = replacement.replace("<slot />", slot_replacement);
246
260
 
247
261
  result.replace_range(*start..*end, &replacement);
248
262
  }
package/index.js CHANGED
@@ -1,2 +1,40 @@
1
- // Re-export from runtime for backwards compatibility
2
- export { default } from './runtime/index.js';
1
+ // Universal entry point for Vibe
2
+ // Usage: import vibe from 'vibe/index.js'; vibe({ initialState }, { debug: true }, 'body');
3
+
4
+ import { ensureBoot, boot, isBooted } from './boot.js';
5
+
6
+ const vibe = (state = {}, config, targetSelector) => {
7
+ if (isBooted()) {
8
+ // Already booted - mutate live state
9
+ Object.assign(window.$, state);
10
+ return window.$;
11
+ }
12
+
13
+ // Not booted yet - accumulate in global state registry
14
+ if (!window.__vibeGlobalState) {
15
+ window.__vibeGlobalState = {};
16
+ }
17
+ Object.assign(window.__vibeGlobalState, state);
18
+
19
+ // Store config (first caller wins)
20
+ if (config && !window.__vibeConfig) {
21
+ window.__vibeConfig = config;
22
+ }
23
+
24
+ // Store targetSelector (first caller wins)
25
+ if (targetSelector && !window.__vibeTargetSelector) {
26
+ window.__vibeTargetSelector = targetSelector;
27
+ }
28
+
29
+ // Explicit boot call (no state passed means "boot now")
30
+ if (Object.keys(state).length === 0 && Object.keys(window.__vibeGlobalState).length > 0) {
31
+ return boot();
32
+ }
33
+
34
+ // Ensure boot happens in microtask
35
+ ensureBoot();
36
+
37
+ return null;
38
+ };
39
+
40
+ export default vibe;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "1.1.2",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",
@@ -15,6 +15,7 @@
15
15
  },
16
16
  "files": [
17
17
  "index.js",
18
+ "vibe.css",
18
19
  "runtime/",
19
20
  "compiler/bin/",
20
21
  "compiler/native/",