@ape-egg/vibe 2.1.22 → 3.0.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.
Files changed (56) hide show
  1. package/README.md +112 -5
  2. package/boot.js +4 -4
  3. package/component.js +27 -29
  4. package/hot-module-refresh.js +4 -4
  5. package/index.js +26 -17
  6. package/llms.txt +36 -5
  7. package/package.json +20 -14
  8. package/runtime/affected.js +159 -36
  9. package/runtime/cleanup.js +45 -1
  10. package/runtime/component.js +360 -98
  11. package/runtime/conditionals.js +111 -14
  12. package/runtime/debug.js +24 -0
  13. package/runtime/dispatch.js +172 -0
  14. package/runtime/hydrate.js +277 -110
  15. package/runtime/index.js +189 -65
  16. package/runtime/iterate.js +125 -50
  17. package/runtime/iteration-utils.js +59 -8
  18. package/runtime/manifest.js +77 -2
  19. package/runtime/parse.js +81 -11
  20. package/runtime/pre-compiled-iterations.js +19 -6
  21. package/runtime/pre-compiled-manifest.js +13 -4
  22. package/runtime/staging.js +153 -0
  23. package/runtime/state.js +31 -0
  24. package/runtime/tracking.js +173 -0
  25. package/runtime/utils.js +155 -78
  26. package/spa.js +206 -0
  27. package/vibe.css +8 -4
  28. package/CHANGELOG.md +0 -1159
  29. package/ROADMAP.md +0 -397
  30. package/compiler/bin/vibe-compile.js +0 -121
  31. package/compiler/native/.gitkeep +0 -0
  32. package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
  33. package/compiler/native/vibe-compiler-linux-x64 +0 -0
  34. package/compiler/src/Cargo.lock +0 -2023
  35. package/compiler/src/Cargo.toml +0 -38
  36. package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +0 -241
  37. package/compiler/src/compiler/binding_case.rs +0 -88
  38. package/compiler/src/compiler/compile.rs +0 -2522
  39. package/compiler/src/compiler/component_tagger.rs +0 -469
  40. package/compiler/src/compiler/iteration_optimizer.rs +0 -455
  41. package/compiler/src/compiler/js_analyzer.rs +0 -715
  42. package/compiler/src/compiler/manifest_builder.rs +0 -693
  43. package/compiler/src/compiler/mod.rs +0 -15
  44. package/compiler/src/compiler/name_binding_protect.rs +0 -207
  45. package/compiler/src/compiler/reassignment_analyzer.rs +0 -456
  46. package/compiler/src/compiler/state_extractor.rs +0 -263
  47. package/compiler/src/compiler/value_stamper.rs +0 -921
  48. package/compiler/src/compiler/watcher.rs +0 -1147
  49. package/compiler/src/config.rs +0 -239
  50. package/compiler/src/main.rs +0 -347
  51. package/compiler/src/parser/element.rs +0 -96
  52. package/compiler/src/parser/html.rs +0 -1004
  53. package/compiler/src/parser/mod.rs +0 -8
  54. package/runtime/pre-compiled-manifest.test.mjs +0 -58
  55. package/runtime/scope.js +0 -50
  56. package/test-results/.last-run.json +0 -4
@@ -1,921 +0,0 @@
1
- use regex::{Regex, Captures};
2
- use serde_json::{Map, Value};
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
- }
18
-
19
- // Elements that should not have reactive bindings processed
20
- // Matches runtime/constants.js NON_REACTIVE_ELEMENTS
21
- const NON_REACTIVE_ELEMENTS: &[&str] = &["script", "head", "pre"];
22
-
23
- // Attributes where the string value is meaningful (should NOT be removed when falsy)
24
- // All other attributes are treated as boolean-like (removed when falsy)
25
- // Matches runtime/constants.js VALUE_ATTRS
26
- const VALUE_ATTRS: &[&str] = &[
27
- // Global attributes
28
- "class", "style", "id", "title", "lang", "dir", "tabindex", "accesskey",
29
- "slot", "part", "is", "nonce", "popover", "anchor",
30
- // Enumerated attributes
31
- "contenteditable", "draggable", "spellcheck", "translate", "autocapitalize",
32
- "inputmode", "enterkeyhint", "virtualkeyboardpolicy",
33
- // URLs and sources
34
- "href", "src", "action", "cite", "data", "poster", "srcset", "imagesrcset",
35
- "formaction", "ping", "usemap", "manifest", "codebase",
36
- // Form attributes
37
- "name", "type", "value", "placeholder", "pattern", "min", "max", "step",
38
- "minlength", "maxlength", "size", "accept", "autocomplete", "list", "form",
39
- "formmethod", "formtarget", "formenctype", "wrap", "method", "enctype", "for", "dirname",
40
- // Text/accessibility
41
- "alt", "label", "summary", "abbr",
42
- // Dimensions and layout
43
- "width", "height", "cols", "rows", "span", "rowspan", "colspan",
44
- "low", "high", "optimum",
45
- // Link/resource hints
46
- "target", "rel", "hreflang", "download", "as", "media", "charset",
47
- "crossorigin", "integrity", "loading", "decoding", "fetchpriority",
48
- "referrerpolicy", "blocking", "imagesizes", "sizes",
49
- // Media
50
- "preload", "kind", "srclang",
51
- // Meta
52
- "content", "http-equiv",
53
- // iframe/embed
54
- "sandbox", "allow", "srcdoc", "credentialless",
55
- // Table
56
- "headers", "scope",
57
- // Datetime
58
- "datetime",
59
- // Object/embed legacy
60
- "coords", "shape",
61
- ];
62
-
63
- pub struct ValueStamper<'a> {
64
- state: &'a Value,
65
- binding_regex: Regex,
66
- _runtime: Runtime, // Must be kept alive for context to work
67
- context: Context,
68
- components_as_is: bool,
69
- /// Global state keys proven constant by reassignment analysis, mapped to the
70
- /// text they stamp to. Only an `@[key]` binding whose expression is EXACTLY
71
- /// one of these keys is stamped from here — never a compound expression — so
72
- /// there is no risk of a dynamic operand leaking into the value.
73
- constant_texts: HashMap<String, String>,
74
- }
75
-
76
- impl<'a> ValueStamper<'a> {
77
- pub fn new(state: &'a Value, components_as_is: bool) -> Result<Self, String> {
78
- Self::with_constants(state, components_as_is, &Map::new())
79
- }
80
-
81
- pub fn with_constants(
82
- state: &'a Value,
83
- components_as_is: bool,
84
- constants: &Map<String, Value>,
85
- ) -> Result<Self, String> {
86
- let runtime = Runtime::new().map_err(|e| format!("Failed to create QuickJS runtime: {:?}", e))?;
87
- let context = Context::full(&runtime).map_err(|e| format!("Failed to create QuickJS context: {:?}", e))?;
88
-
89
- // Set up state ONCE when creating the stamper (not per expression)
90
- context.with(|ctx| -> Result<(), String> {
91
- let state_str = serde_json::to_string(state)
92
- .map_err(|e| format!("Failed to serialize state: {}", e))?;
93
- // Expose state both as bare globals (via Object.assign) and as a
94
- // persistent `$` global carrying the runtime `unsafe` helper, so a
95
- // `@[$.unsafe(expr)]` binding stamps its trusted string raw at build
96
- // time — mirroring the runtime, where `$.unsafe` marks raw HTML.
97
- ctx.eval::<(), _>(format!(
98
- "const $ = {}; Object.assign(globalThis, $); $.unsafe = (s) => s; globalThis.$ = $",
99
- state_str
100
- ))
101
- .map_err(|e| format!("Failed to set up state in QuickJS: {:?}", e))?;
102
- Ok(())
103
- })?;
104
-
105
- let constant_texts = constants
106
- .iter()
107
- .map(|(key, value)| (key.clone(), primitive_to_text(value)))
108
- .collect();
109
-
110
- Ok(Self {
111
- state,
112
- binding_regex: Regex::new(r"@\[((?:[^\[\]]|\[[^\]]*\])+)\]").unwrap(),
113
- _runtime: runtime,
114
- context,
115
- components_as_is,
116
- constant_texts,
117
- })
118
- }
119
-
120
- pub fn stamp_html(&self, html: String) -> Result<String, String> {
121
- // Extract vibe-dehydrate content to protect it from processing
122
- // This prevents @[...] markers and iterations/conditionals from being processed
123
- let dehydrate_regex = Regex::new(r"(?s)<template[^>]*\svibe-dehydrate[^>]*>.*?</template>").unwrap();
124
- let mut dehydrate_placeholders: Vec<String> = Vec::new();
125
- let mut html_with_placeholders = html.clone();
126
-
127
- for (i, mat) in dehydrate_regex.find_iter(&html).enumerate() {
128
- let content = mat.as_str();
129
- let placeholder = format!("<!--VIBE_DEHYDRATE_PLACEHOLDER_{}-->", i);
130
- dehydrate_placeholders.push(content.to_string());
131
- html_with_placeholders = html_with_placeholders.replace(content, &placeholder);
132
- }
133
-
134
- // First, render iterations (expands templates into multiple instances)
135
- let html = self.render_iterations(html_with_placeholders)?;
136
-
137
- // Then, render conditionals (resolve if/else branches)
138
- let html = self.render_conditionals(html)?;
139
-
140
- // Find all NON_REACTIVE_ELEMENT regions to skip
141
- let mut skip_regions: Vec<(usize, usize)> = Vec::new();
142
-
143
- for element_type in NON_REACTIVE_ELEMENTS {
144
- let regex = Regex::new(&format!(r"(?s)<{}[^>]*>.*?</{}>", element_type, element_type)).unwrap();
145
- for mat in regex.find_iter(&html) {
146
- skip_regions.push((mat.start(), mat.end()));
147
- }
148
- }
149
-
150
- // Skip elements with vibe-dehydrate attribute (content should not be processed)
151
- // This is a secondary protection in case placeholders aren't used
152
- let dehydrate_regex = Regex::new(r"(?s)<template[^>]*\svibe-dehydrate[^>]*>.*?</template>").unwrap();
153
- for mat in dehydrate_regex.find_iter(&html) {
154
- skip_regions.push((mat.start(), mat.end()));
155
- }
156
-
157
- // When components_as_is is true, skip component elements (runtime will handle them)
158
- // Match both <component> and <div class="component"> elements
159
- if self.components_as_is {
160
- // Match <component ...>...</component>
161
- let component_regex = Regex::new(r"(?s)<component[^>]*>.*?</component>").unwrap();
162
- for mat in component_regex.find_iter(&html) {
163
- skip_regions.push((mat.start(), mat.end()));
164
- }
165
-
166
- // Match <div class="component" ...>...</div>
167
- let div_component_regex = Regex::new(r#"(?s)<div\s+class="component"[^>]*>.*?</div>"#).unwrap();
168
- for mat in div_component_regex.find_iter(&html) {
169
- skip_regions.push((mat.start(), mat.end()));
170
- }
171
- }
172
-
173
- // Sort and merge overlapping regions
174
- skip_regions.sort_by_key(|r| r.0);
175
-
176
- // Stamp bindings only in regions that are NOT in skip_regions
177
- let mut result = String::new();
178
- let mut last_pos = 0;
179
-
180
- for binding_match in self.binding_regex.find_iter(&html) {
181
- let match_start = binding_match.start();
182
- let match_end = binding_match.end();
183
-
184
- // Check if this binding is inside a skip region
185
- let should_skip = skip_regions.iter().any(|(start, end)| {
186
- match_start >= *start && match_end <= *end
187
- });
188
-
189
- // Add text before this match
190
- result.push_str(&html[last_pos..match_start]);
191
-
192
- if should_skip {
193
- // Keep the binding marker as-is
194
- result.push_str(binding_match.as_str());
195
- } else {
196
- // Stamp the binding
197
- let caps = self.binding_regex.captures(binding_match.as_str()).unwrap();
198
- let expr = &caps[1];
199
- // A bare reference to a proven-constant global key (`@[version]`)
200
- // stamps its build-time value. Any compound expression falls
201
- // through to normal evaluation, where dynamic globals stay absent
202
- // and the binding is left live for the runtime.
203
- if let Some(text) = self.constant_texts.get(expr.trim()) {
204
- result.push_str(text);
205
- } else {
206
- let stamped = self.eval_expression(expr)
207
- .unwrap_or_else(|| binding_match.as_str().to_string());
208
- result.push_str(&stamped);
209
- }
210
- }
211
-
212
- last_pos = match_end;
213
- }
214
-
215
- // Add remaining text
216
- result.push_str(&html[last_pos..]);
217
-
218
- // Clean up attributes that still contain unresolved markers
219
- result = self.cleanup_unresolved_attributes(result);
220
-
221
- // Restore vibe-dehydrate content from placeholders
222
- for (i, content) in dehydrate_placeholders.iter().enumerate() {
223
- let placeholder = format!("<!--VIBE_DEHYDRATE_PLACEHOLDER_{}-->", i);
224
- result = result.replace(&placeholder, content);
225
- }
226
-
227
- // Finally, clean up boolean-like attributes with falsy values
228
- // When components_as_is is true, skip cleanup inside component elements
229
- Ok(self.cleanup_boolean_attributes(result, &skip_regions))
230
- }
231
-
232
- /// Evaluate a JavaScript expression with the current state
233
- fn eval_expression(&self, expr: &str) -> Option<String> {
234
- self.eval_with_state(expr, self.state)
235
- }
236
-
237
- /// Evaluate a JavaScript expression with custom state (for nested iterations)
238
- fn eval_with_state(&self, expr: &str, _state: &Value) -> Option<String> {
239
- // State is already set up in the context during new(), just evaluate the expression
240
- self.context.with(|ctx| -> Option<String> {
241
- // Evaluate expression and convert to string
242
- let result: rquickjs::Value = ctx.eval(expr).ok()?;
243
-
244
- // Convert result to string
245
- if result.is_string() {
246
- result.as_string().and_then(|s| s.to_string().ok())
247
- } else if result.is_number() {
248
- result.as_number().map(|n| n.to_string())
249
- } else if result.is_bool() {
250
- result.as_bool().map(|b| b.to_string())
251
- } else if result.is_null() {
252
- Some(String::new())
253
- } else if result.is_undefined() {
254
- None
255
- } else {
256
- // For objects/arrays, convert to JSON
257
- ctx.json_stringify(result).ok()
258
- .and_then(|v| v.and_then(|s| s.to_string().ok()))
259
- }
260
- })
261
- }
262
-
263
- fn render_iterations(&self, html: String) -> Result<String, String> {
264
- // Use depth counting to properly handle nested iterations
265
- self.render_iterations_recursive(html)
266
- }
267
-
268
- fn render_iterations_recursive(&self, html: String) -> Result<String, String> {
269
- // Matches every `<!-- each PATH as ITEM[, INDEX][ (KEY)] -->` form: the
270
- // array PATH may be any expression (a call like `filter(...)`, not just a
271
- // dotted path) and the optional `(KEY)` keyed-iteration suffix is captured
272
- // so it can be preserved on the rebuilt marker.
273
- let each_start = Regex::new(r"<!--\s*each\s+(.+?)\s+as\s+(\w+)(?:\s*,\s*(\w+))?(?:\s*\(([^)]*)\))?\s*-->").unwrap();
274
-
275
- let mut result = html.clone();
276
- let mut search_pos = 0;
277
-
278
- while let Some(start_match) = each_start.find_at(&result, search_pos) {
279
- // Extract positions before doing anything else
280
- let start_pos = start_match.start();
281
- let template_start = start_match.end();
282
-
283
- let captures = each_start.captures(&result[start_pos..]).unwrap();
284
- let array_path = captures.get(1).unwrap().as_str().to_string();
285
- let item_alias = captures.get(2).unwrap().as_str().to_string();
286
- let index_alias = captures.get(3).map(|m| m.as_str().to_string()).unwrap_or_else(|| "index".to_string());
287
- let has_index = captures.get(3).is_some();
288
- let key_part = captures.get(4).map(|m| format!(" ({})", m.as_str())).unwrap_or_default();
289
-
290
- // Find matching <!-- /each --> using depth counting
291
- if let Some((end_pos, end_after)) = self.find_matching_end(&result, template_start) {
292
- let template = result[template_start..end_pos].to_string();
293
-
294
- // Resolve the array from the stamp state. An array we can't resolve
295
- // (a dynamic global like `notifications`, empty at compile time) is
296
- // treated as empty: render zero items so the raw `@[item.x]`
297
- // template body is dropped from the painted DOM. The runtime
298
- // restores the template from the manifest (built pre-stamp) and
299
- // fills it in when the live array gets data.
300
- let array = self.eval_array_path(&array_path).unwrap_or_default();
301
-
302
- // Render each item
303
- let mut rendered_items = Vec::new();
304
- for (idx, item) in array.iter().enumerate() {
305
- // Create temporary merged state for nested iterations
306
- // This allows nested iterations to reference the parent item (e.g., category.items)
307
- let mut merged_state = self.state.clone();
308
- if let Some(obj) = merged_state.as_object_mut() {
309
- obj.insert(item_alias.clone(), item.clone());
310
- obj.insert(index_alias.clone(), Value::Number(idx.into()));
311
- }
312
-
313
- // Create temp stamper with merged state for this iteration
314
- let temp_stamper = ValueStamper::new(&merged_state, false)
315
- .map_err(|e| format!("Failed to create stamper for iteration: {}", e))?;
316
-
317
- // Render nested iterations first
318
- let mut item_html = temp_stamper.render_iterations_recursive(template.clone())?;
319
-
320
- // Then render conditionals with this item's context
321
- item_html = temp_stamper.render_conditionals(item_html)?;
322
-
323
- // Finally stamp all bindings in this item's context
324
- item_html = temp_stamper.binding_regex.replace_all(&item_html, |caps: &Captures| {
325
- let expr = &caps[1];
326
- temp_stamper.eval_expression(expr)
327
- .unwrap_or_else(|| caps[0].to_string())
328
- }).to_string();
329
-
330
- // Clean up attributes with unresolved markers (e.g., data-tutorial="@[item.id]" when item.id is undefined)
331
- item_html = temp_stamper.cleanup_unresolved_attributes(item_html);
332
-
333
- rendered_items.push(item_html);
334
- }
335
-
336
- // Reconstruct with comments
337
- let index_part = if has_index {
338
- format!(", {}", index_alias)
339
- } else {
340
- String::new()
341
- };
342
-
343
- let replacement = format!(
344
- "<!-- each {} as {}{}{} -->{}<!-- /each -->",
345
- array_path,
346
- item_alias,
347
- index_part,
348
- key_part,
349
- rendered_items.join("")
350
- );
351
-
352
- // Replace the entire iteration block
353
- result.replace_range(start_pos..end_after, &replacement);
354
-
355
- // Continue searching after the replacement
356
- search_pos = start_pos + replacement.len();
357
- } else {
358
- break;
359
- }
360
- }
361
-
362
- Ok(result)
363
- }
364
-
365
- /// Evaluate an array path and return the array
366
- fn eval_array_path(&self, path: &str) -> Option<Vec<Value>> {
367
- let runtime = Runtime::new().ok()?;
368
- let context = Context::full(&runtime).ok()?;
369
-
370
- context.with(|ctx| -> Option<Vec<Value>> {
371
- // Set state properties in global scope
372
- let state_str = serde_json::to_string(self.state).ok()?;
373
- ctx.eval::<(), _>(format!("const $ = {}; Object.assign(globalThis, $)", state_str)).ok()?;
374
-
375
- // Evaluate path to get array
376
- let result: rquickjs::Value = ctx.eval(path).ok()?;
377
-
378
- // Convert to JSON and parse back to Vec<Value>
379
- let json_str = ctx.json_stringify(result).ok()??;
380
- let json_str = json_str.to_string().ok()?;
381
- serde_json::from_str(&json_str).ok()
382
- })
383
- }
384
-
385
- /// Find matching <!-- /each --> using depth counting
386
- fn find_matching_end(&self, html: &str, start_pos: usize) -> Option<(usize, usize)> {
387
- let each_start = Regex::new(r"<!--\s*each\s+").unwrap();
388
- let end_comment = Regex::new(r"<!--\s*/each\s*-->").unwrap();
389
-
390
- let mut depth = 1;
391
- let mut search_pos = start_pos;
392
-
393
- while depth > 0 {
394
- let next_start = each_start.find_at(html, search_pos);
395
- let next_end = end_comment.find_at(html, search_pos);
396
-
397
- match (next_start, next_end) {
398
- (Some(start), Some(end)) if start.start() < end.start() => {
399
- depth += 1;
400
- search_pos = start.end();
401
- }
402
- (_, Some(end)) => {
403
- depth -= 1;
404
- if depth == 0 {
405
- return Some((end.start(), end.end()));
406
- }
407
- search_pos = end.end();
408
- }
409
- _ => return None,
410
- }
411
- }
412
-
413
- None
414
- }
415
-
416
- fn render_conditionals(&self, html: String) -> Result<String, String> {
417
- let if_start = Regex::new(r"<!--\s*if\s+(.+?)\s*-->").unwrap();
418
- let else_comment = Regex::new(r"<!--\s*else\s*-->").unwrap();
419
-
420
- let mut result = html.clone();
421
- let mut search_pos = 0;
422
-
423
- while let Some(start_match) = if_start.find_at(&result, search_pos) {
424
- let start_pos = start_match.start();
425
- let template_start = start_match.end();
426
-
427
- // Extract condition expression
428
- let captures = if_start.captures(&result[start_pos..]).unwrap();
429
- let condition = captures.get(1).unwrap().as_str();
430
-
431
- // Try to evaluate condition - if it fails (e.g., references iteration variables),
432
- // skip this conditional and let runtime handle it
433
- let condition_result = match self.try_eval_condition(condition) {
434
- Some(result) => result,
435
- None => {
436
- // Can't evaluate (likely references iteration variables)
437
- // Skip and let runtime handle it
438
- search_pos = template_start;
439
- continue;
440
- }
441
- };
442
-
443
- // Find matching <!-- /if -->
444
- if let Some((end_pos, end_after)) = self.find_matching_conditional_end(&result, template_start) {
445
- // Find <!-- else --> at the same depth level (not nested inside inner conditionals)
446
- let else_pos = self.find_else_at_same_level(&result, template_start, end_pos);
447
-
448
- // Keep ALL markers (if/else/endif) regardless of which branch is taken
449
- // This allows restoration to understand the structure
450
- let if_comment = &result[start_pos..template_start];
451
-
452
- let replacement = if let Some(else_start) = else_pos {
453
- let else_match = else_comment.find_at(&result, else_start).unwrap();
454
- let else_marker = &result[else_match.start()..else_match.end()];
455
-
456
- if condition_result {
457
- // True branch: stamp if content, keep else empty
458
- let if_content = result[template_start..else_start].trim().to_string();
459
- format!("{}{}{}<!-- /if -->", if_comment, if_content, else_marker)
460
- } else {
461
- // False branch: keep if empty, stamp else content
462
- let else_end = else_match.end();
463
- let else_content = result[else_end..end_pos].trim().to_string();
464
- format!("{}{}{}<!-- /if -->", if_comment, else_marker, else_content)
465
- }
466
- } else {
467
- // No else branch - only stamp if content was true, otherwise empty
468
- let content = if condition_result {
469
- result[template_start..end_pos].trim().to_string()
470
- } else {
471
- String::new()
472
- };
473
- format!("{}{}<!-- /if -->", if_comment, content)
474
- };
475
-
476
- // Replace entire conditional block
477
- result.replace_range(start_pos..end_after, &replacement);
478
-
479
- // Continue searching after replacement
480
- search_pos = start_pos + replacement.len();
481
- } else {
482
- break;
483
- }
484
- }
485
-
486
- Ok(result)
487
- }
488
-
489
- fn find_matching_conditional_end(&self, html: &str, start_pos: usize) -> Option<(usize, usize)> {
490
- let if_start = Regex::new(r"<!--\s*if\s+.+?\s*-->").unwrap();
491
- let if_end = Regex::new(r"<!--\s*/if\s*-->").unwrap();
492
-
493
- let mut depth = 1;
494
- let mut search_pos = start_pos;
495
-
496
- while depth > 0 {
497
- let next_start = if_start.find_at(html, search_pos);
498
- let next_end = if_end.find_at(html, search_pos);
499
-
500
- match (next_start, next_end) {
501
- (Some(start), Some(end)) if start.start() < end.start() => {
502
- depth += 1;
503
- search_pos = start.end();
504
- }
505
- (_, Some(end)) => {
506
- depth -= 1;
507
- if depth == 0 {
508
- return Some((end.start(), end.end()));
509
- }
510
- search_pos = end.end();
511
- }
512
- _ => return None,
513
- }
514
- }
515
-
516
- None
517
- }
518
-
519
- fn find_else_at_same_level(&self, html: &str, start_pos: usize, end_pos: usize) -> Option<usize> {
520
- let if_start = Regex::new(r"<!--\s*if\s+.+?\s*-->").unwrap();
521
- let if_end = Regex::new(r"<!--\s*/if\s*-->").unwrap();
522
- let else_comment = Regex::new(r"<!--\s*else\s*-->").unwrap();
523
-
524
- let mut depth = 0;
525
- let mut search_pos = start_pos;
526
-
527
- while search_pos < end_pos {
528
- // Find the next comment (could be if, else, or /if)
529
- let next_if = if_start.find_at(html, search_pos);
530
- let next_else = else_comment.find_at(html, search_pos);
531
- let next_end = if_end.find_at(html, search_pos);
532
-
533
- // Determine which comment comes first
534
- let mut next_pos = end_pos;
535
- let mut next_type = None; // 0=if, 1=else, 2=end
536
-
537
- if let Some(m) = next_if {
538
- if m.start() < next_pos {
539
- next_pos = m.start();
540
- next_type = Some(0);
541
- }
542
- }
543
- if let Some(m) = next_else {
544
- if m.start() < next_pos {
545
- next_pos = m.start();
546
- next_type = Some(1);
547
- }
548
- }
549
- if let Some(m) = next_end {
550
- if m.start() < next_pos {
551
- next_pos = m.start();
552
- next_type = Some(2);
553
- }
554
- }
555
-
556
- match next_type {
557
- Some(0) => {
558
- // Found nested if - increase depth
559
- depth += 1;
560
- search_pos = next_if.unwrap().end();
561
- }
562
- Some(1) => {
563
- // Found else
564
- if depth == 0 {
565
- // This is the else at our level!
566
- return Some(next_pos);
567
- }
568
- search_pos = next_else.unwrap().end();
569
- }
570
- Some(2) => {
571
- // Found end - decrease depth
572
- depth -= 1;
573
- search_pos = next_end.unwrap().end();
574
- }
575
- None => {
576
- // No more comments found
577
- break;
578
- }
579
- _ => break,
580
- }
581
- }
582
-
583
- None
584
- }
585
-
586
- fn try_eval_condition(&self, condition: &str) -> Option<bool> {
587
- self.context.with(|ctx| -> Option<bool> {
588
- // Try to evaluate condition
589
- let result: rquickjs::Value = match ctx.eval(condition) {
590
- Ok(val) => val,
591
- Err(_) => {
592
- // Evaluation failed (undefined variable, syntax error, etc.)
593
- // Default to false as per requirements
594
- return Some(false);
595
- }
596
- };
597
-
598
- // Use JavaScript truthiness rules instead of requiring boolean type
599
- // This allows conditions like "item.id" to work correctly
600
- if result.is_bool() {
601
- result.as_bool()
602
- } else if result.is_null() || result.is_undefined() {
603
- Some(false)
604
- } else if result.is_number() {
605
- // 0, NaN are falsy
606
- result.as_number().map(|n| n != 0.0 && !n.is_nan())
607
- } else if result.is_string() {
608
- // Empty string is falsy
609
- result.as_string().and_then(|s| s.to_string().ok()).map(|s| !s.is_empty())
610
- } else {
611
- // Objects, arrays are truthy
612
- Some(true)
613
- }
614
- })
615
- }
616
-
617
- /// Remove boolean-like attributes with falsy values, normalize truthy ones
618
- /// Boolean-like attributes (not in VALUE_ATTRS) should be:
619
- /// - Removed entirely when falsy
620
- /// - Present with empty value when truthy (HTML5 boolean attribute syntax)
621
- /// Remove attributes that still contain unresolved markers (e.g., data-tutorial="@[item.id]")
622
- /// These occur when iterating over items where some don't have the referenced property
623
- fn cleanup_unresolved_attributes(&self, html: String) -> String {
624
- // Match attributes with marker values: attr="@[...]"
625
- // Use non-greedy match and look for closing "]" at end of attribute value
626
- let attr_with_marker = Regex::new(r#"\s+([\w-]+)="@\[.+?\]""#).unwrap();
627
-
628
- attr_with_marker.replace_all(&html, |_caps: &Captures| {
629
- // Remove the entire attribute when it has an unresolved marker
630
- String::new()
631
- }).to_string()
632
- }
633
-
634
- fn cleanup_boolean_attributes(&self, html: String, skip_regions: &[(usize, usize)]) -> String {
635
- // Create a set for faster lookup
636
- let value_attrs: HashSet<&str> = VALUE_ATTRS.iter().copied().collect();
637
-
638
- // Match attributes with their values: attr="value"
639
- let attr_regex = Regex::new(r#"\s+([\w-]+)="([^"]*)""#).unwrap();
640
-
641
- attr_regex.replace_all(&html, |caps: &Captures| {
642
- let match_start = caps.get(0).unwrap().start();
643
- let match_end = caps.get(0).unwrap().end();
644
-
645
- // Check if this attribute is inside a skip region
646
- let in_skip_region = skip_regions.iter().any(|(start, end)| {
647
- match_start >= *start && match_end <= *end
648
- });
649
-
650
- // If in skip region, keep attribute as-is
651
- if in_skip_region {
652
- return caps[0].to_string();
653
- }
654
-
655
- let attr_name = caps[1].to_lowercase();
656
- let attr_value = &caps[2];
657
-
658
- // Check if this is a value attribute (should keep its value)
659
- // Includes: VALUE_ATTRS list + data-* + aria-* + on* (event handlers)
660
- let is_value_attr = value_attrs.contains(attr_name.as_str())
661
- || attr_name.starts_with("data-")
662
- || attr_name.starts_with("aria-")
663
- || attr_name.starts_with("on");
664
-
665
- if is_value_attr {
666
- // Keep value attributes as-is
667
- return caps[0].to_string();
668
- }
669
-
670
- // Check if value is explicitly falsy
671
- let is_falsy = matches!(
672
- attr_value,
673
- "false" | "0" | "null" | "undefined"
674
- );
675
-
676
- if is_falsy {
677
- // Remove the entire attribute (return empty string)
678
- String::new()
679
- } else if attr_value.is_empty() {
680
- // Convert boolean attributes with empty values to short form: `demo=""` -> `demo`
681
- format!(" {}", &caps[1])
682
- } else {
683
- // Keep the attribute as-is
684
- caps[0].to_string()
685
- }
686
- }).to_string()
687
- }
688
- }
689
-
690
- #[cfg(test)]
691
- mod tests {
692
- use super::*;
693
- use serde_json::json;
694
-
695
- #[test]
696
- fn stamp_simple_binding() {
697
- let state = json!({ "name": "World" });
698
- let stamper = ValueStamper::new(&state, false).unwrap();
699
- let html = String::from("<div>Hello @[name]</div>");
700
- let result = stamper.stamp_html(html).unwrap();
701
- assert_eq!(result, "<div>Hello World</div>");
702
- }
703
-
704
- #[test]
705
- fn stamp_multiple_bindings() {
706
- let state = json!({ "firstName": "John", "lastName": "Doe" });
707
- let stamper = ValueStamper::new(&state, false).unwrap();
708
- let html = String::from("<div>@[firstName] @[lastName]</div>");
709
- let result = stamper.stamp_html(html).unwrap();
710
- assert_eq!(result, "<div>John Doe</div>");
711
- }
712
-
713
- #[test]
714
- fn stamp_nested_binding() {
715
- let state = json!({ "user": { "name": "John" } });
716
- let stamper = ValueStamper::new(&state, false).unwrap();
717
- let html = String::from("<div>Hello @[user.name]</div>");
718
- let result = stamper.stamp_html(html).unwrap();
719
- assert_eq!(result, "<div>Hello John</div>");
720
- }
721
-
722
- #[test]
723
- fn stamp_array_length() {
724
- let state = json!({ "items": ["first", "second", "third"] });
725
- let stamper = ValueStamper::new(&state, false).unwrap();
726
- let html = String::from("<div>@[items.length]</div>");
727
- let result = stamper.stamp_html(html).unwrap();
728
- assert_eq!(result, "<div>3</div>");
729
- }
730
-
731
- #[test]
732
- fn stamp_array_binding() {
733
- let state = json!({ "items": ["first", "second", "third"] });
734
- let stamper = ValueStamper::new(&state, false).unwrap();
735
- let html = String::from("<div>@[items[0]]</div>");
736
- let result = stamper.stamp_html(html).unwrap();
737
- assert_eq!(result, "<div>first</div>");
738
- }
739
-
740
- #[test]
741
- fn stamp_number_binding() {
742
- let state = json!({ "count": 42 });
743
- let stamper = ValueStamper::new(&state, false).unwrap();
744
- let html = String::from("<div>Count: @[count]</div>");
745
- let result = stamper.stamp_html(html).unwrap();
746
- assert_eq!(result, "<div>Count: 42</div>");
747
- }
748
-
749
- #[test]
750
- fn stamp_boolean_binding() {
751
- let state = json!({ "isActive": true });
752
- let stamper = ValueStamper::new(&state, false).unwrap();
753
- let html = String::from("<div>Active: @[isActive]</div>");
754
- let result = stamper.stamp_html(html).unwrap();
755
- assert_eq!(result, "<div>Active: true</div>");
756
- }
757
-
758
- #[test]
759
- fn stamp_missing_binding() {
760
- let state = json!({ "name": "World" });
761
- let stamper = ValueStamper::new(&state, false).unwrap();
762
- let html = String::from("<div>Hello @[missing]</div>");
763
- let result = stamper.stamp_html(html).unwrap();
764
- // Should keep the binding marker if value doesn't exist
765
- assert_eq!(result, "<div>Hello @[missing]</div>");
766
- }
767
-
768
- #[test]
769
- fn stamp_unsafe_emits_raw_html() {
770
- let state = json!({ "desc": "Gain <span data-green>+3%</span>." });
771
- let stamper = ValueStamper::new(&state, false).unwrap();
772
- let html = String::from("<p>@[$.unsafe(desc)]</p>");
773
- let result = stamper.stamp_html(html).unwrap();
774
- // Raw markup is painted directly — not escaped, not left as a marker.
775
- assert_eq!(result, "<p>Gain <span data-green>+3%</span>.</p>");
776
- }
777
-
778
- #[test]
779
- fn stamp_unsafe_nested_path() {
780
- let state = json!({ "tooltip": { "props": { "description": "<em>x</em>" } } });
781
- let stamper = ValueStamper::new(&state, false).unwrap();
782
- let html = String::from("<p>@[$.unsafe(tooltip.props.description)]</p>");
783
- let result = stamper.stamp_html(html).unwrap();
784
- assert_eq!(result, "<p><em>x</em></p>");
785
- }
786
-
787
- #[test]
788
- fn stamp_attribute_binding() {
789
- let state = json!({ "firstName": "John" });
790
- let stamper = ValueStamper::new(&state, false).unwrap();
791
- let html = String::from(r#"<input value="@[firstName]">"#);
792
- let result = stamper.stamp_html(html).unwrap();
793
- assert_eq!(result, r#"<input value="John">"#);
794
- }
795
-
796
- #[test]
797
- fn render_simple_iteration() {
798
- let state = json!({ "items": [1, 2, 3] });
799
- let stamper = ValueStamper::new(&state, false).unwrap();
800
- let html = String::from(r#"<!-- each items as item --><div>@[item]</div><!-- /each -->"#);
801
- let result = stamper.stamp_html(html).unwrap();
802
- assert_eq!(result, r#"<!-- each items as item --><div>1</div><div>2</div><div>3</div><!-- /each -->"#);
803
- }
804
-
805
- #[test]
806
- fn render_iteration_with_index() {
807
- let state = json!({ "items": ["a", "b", "c"] });
808
- let stamper = ValueStamper::new(&state, false).unwrap();
809
- let html = String::from(r#"<!-- each items as item, idx --><span>[@[idx]] @[item]</span><!-- /each -->"#);
810
- let result = stamper.stamp_html(html).unwrap();
811
- assert_eq!(result, r#"<!-- each items as item, idx --><span>[0] a</span><span>[1] b</span><span>[2] c</span><!-- /each -->"#);
812
- }
813
-
814
- #[test]
815
- fn unresolvable_iteration_body_is_stripped() {
816
- // `notifications` is a dynamic global, absent from the stamp state. The
817
- // each renders empty so no raw `@[notification.title]` paints, while the
818
- // keyed marker is preserved for runtime restoration from the manifest.
819
- let state = json!({});
820
- let stamper = ValueStamper::new(&state, false).unwrap();
821
- let html = String::from(
822
- "<toasts><!-- each notifications as notification (notification.id) --><toast>@[notification.title]</toast><!-- /each --></toasts>",
823
- );
824
- let result = stamper.stamp_html(html).unwrap();
825
- assert!(!result.contains("@["), "raw binding left behind: {result}");
826
- assert!(
827
- result.contains("<!-- each notifications as notification (notification.id) -->"),
828
- "keyed marker dropped: {result}"
829
- );
830
- assert!(result.contains("<!-- /each -->"));
831
- }
832
-
833
- #[test]
834
- fn unresolvable_complex_path_iteration_is_stripped() {
835
- // The array path is a function call, not a dotted path — still handled.
836
- let state = json!({});
837
- let stamper = ValueStamper::new(&state, false).unwrap();
838
- let html = String::from(
839
- "<!-- each dataInspectorState($, x).rows as row (row.path) --><r>@[row.label]</r><!-- /each -->",
840
- );
841
- let result = stamper.stamp_html(html).unwrap();
842
- assert!(!result.contains("@["), "raw binding left behind: {result}");
843
- assert!(result.contains("<!-- each dataInspectorState($, x).rows as row (row.path) -->"));
844
- }
845
-
846
- #[test]
847
- fn cleanup_boolean_attributes_false() {
848
- let state = json!({ "buttonDisabled": false });
849
- let stamper = ValueStamper::new(&state, false).unwrap();
850
- let html = String::from(r#"<button disabled="@[buttonDisabled]">Click</button>"#);
851
- let result = stamper.stamp_html(html).unwrap();
852
- // disabled="false" should be removed entirely (falsy boolean-like attribute)
853
- assert_eq!(result, r#"<button>Click</button>"#);
854
- }
855
-
856
- #[test]
857
- fn cleanup_boolean_attributes_true() {
858
- let state = json!({ "buttonDisabled": true });
859
- let stamper = ValueStamper::new(&state, false).unwrap();
860
- let html = String::from(r#"<button disabled="@[buttonDisabled]">Click</button>"#);
861
- let result = stamper.stamp_html(html).unwrap();
862
- // disabled="true" should become disabled="" (HTML5 boolean attribute syntax)
863
- assert_eq!(result, r#"<button disabled="">Click</button>"#);
864
- }
865
-
866
- #[test]
867
- fn cleanup_keeps_value_attributes() {
868
- let state = json!({ "inputValue": "false" });
869
- let stamper = ValueStamper::new(&state, false).unwrap();
870
- let html = String::from(r#"<input value="@[inputValue]">"#);
871
- let result = stamper.stamp_html(html).unwrap();
872
- // value="false" should be kept as-is (value attribute, not boolean-like)
873
- assert_eq!(result, r#"<input value="false">"#);
874
- }
875
-
876
- #[test]
877
- fn cleanup_multiple_boolean_attributes() {
878
- let state = json!({ "disabled": false, "readonly": true, "required": false });
879
- let stamper = ValueStamper::new(&state, false).unwrap();
880
- let html = String::from(r#"<input disabled="@[disabled]" readonly="@[readonly]" required="@[required]">"#);
881
- let result = stamper.stamp_html(html).unwrap();
882
- // disabled and required should be removed, readonly should be present with empty value
883
- assert_eq!(result, r#"<input readonly="">"#);
884
- }
885
-
886
- fn constants(pairs: &[(&str, Value)]) -> Map<String, Value> {
887
- pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
888
- }
889
-
890
- #[test]
891
- fn constant_key_binding_is_stamped() {
892
- let state = json!({});
893
- let consts = constants(&[("version", json!("0.1.5"))]);
894
- let stamper = ValueStamper::with_constants(&state, false, &consts).unwrap();
895
- let result = stamper
896
- .stamp_html("<version-tag>BETA v@[version]</version-tag>".to_string())
897
- .unwrap();
898
- assert_eq!(result, "<version-tag>BETA v0.1.5</version-tag>");
899
- }
900
-
901
- #[test]
902
- fn non_constant_binding_stays_raw() {
903
- let state = json!({});
904
- let consts = constants(&[("version", json!("0.1.5"))]);
905
- let stamper = ValueStamper::with_constants(&state, false, &consts).unwrap();
906
- // `coins` is dynamic (not a constant, not in stamp state) → left for runtime.
907
- let result = stamper.stamp_html("<coin-stack>@[coins]</coin-stack>".to_string()).unwrap();
908
- assert_eq!(result, "<coin-stack>@[coins]</coin-stack>");
909
- }
910
-
911
- #[test]
912
- fn constant_never_leaks_into_compound_expression() {
913
- // Only the EXACT `@[version]` binding stamps; `@[version + …]` must not
914
- // pull the constant in (no `0.1.5…` garbage), guarding mixed expressions.
915
- let state = json!({});
916
- let consts = constants(&[("version", json!("0.1.5"))]);
917
- let stamper = ValueStamper::with_constants(&state, false, &consts).unwrap();
918
- let result = stamper.stamp_html("<x>@[version + suffix]</x>".to_string()).unwrap();
919
- assert!(!result.contains("0.1.5"), "constant leaked into compound: {result}");
920
- }
921
- }