@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.
- package/README.md +112 -5
- package/boot.js +4 -4
- package/component.js +27 -29
- package/hot-module-refresh.js +4 -4
- package/index.js +26 -17
- package/llms.txt +36 -5
- package/package.json +20 -14
- package/runtime/affected.js +159 -36
- package/runtime/cleanup.js +45 -1
- package/runtime/component.js +360 -98
- package/runtime/conditionals.js +111 -14
- package/runtime/debug.js +24 -0
- package/runtime/dispatch.js +172 -0
- package/runtime/hydrate.js +277 -110
- package/runtime/index.js +189 -65
- package/runtime/iterate.js +125 -50
- package/runtime/iteration-utils.js +59 -8
- package/runtime/manifest.js +77 -2
- package/runtime/parse.js +81 -11
- package/runtime/pre-compiled-iterations.js +19 -6
- package/runtime/pre-compiled-manifest.js +13 -4
- package/runtime/staging.js +153 -0
- package/runtime/state.js +31 -0
- package/runtime/tracking.js +173 -0
- package/runtime/utils.js +155 -78
- package/spa.js +206 -0
- package/vibe.css +8 -4
- package/CHANGELOG.md +0 -1159
- package/ROADMAP.md +0 -397
- package/compiler/bin/vibe-compile.js +0 -121
- package/compiler/native/.gitkeep +0 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +0 -2023
- package/compiler/src/Cargo.toml +0 -38
- package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +0 -241
- package/compiler/src/compiler/binding_case.rs +0 -88
- package/compiler/src/compiler/compile.rs +0 -2522
- package/compiler/src/compiler/component_tagger.rs +0 -469
- package/compiler/src/compiler/iteration_optimizer.rs +0 -455
- package/compiler/src/compiler/js_analyzer.rs +0 -715
- package/compiler/src/compiler/manifest_builder.rs +0 -693
- package/compiler/src/compiler/mod.rs +0 -15
- package/compiler/src/compiler/name_binding_protect.rs +0 -207
- package/compiler/src/compiler/reassignment_analyzer.rs +0 -456
- package/compiler/src/compiler/state_extractor.rs +0 -263
- package/compiler/src/compiler/value_stamper.rs +0 -921
- package/compiler/src/compiler/watcher.rs +0 -1147
- package/compiler/src/config.rs +0 -239
- package/compiler/src/main.rs +0 -347
- package/compiler/src/parser/element.rs +0 -96
- package/compiler/src/parser/html.rs +0 -1004
- package/compiler/src/parser/mod.rs +0 -8
- package/runtime/pre-compiled-manifest.test.mjs +0 -58
- package/runtime/scope.js +0 -50
- 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
|
-
}
|