@ape-egg/vibe 2.1.4 → 2.1.6

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,456 @@
1
+ //! Reassignment analysis: decide which global `$` state keys are compile-time
2
+ //! constants (safe to value-stamp into pre-rendered HTML) versus runtime-dynamic
3
+ //! (must be left as live `@[...]` bindings for the runtime to fill).
4
+ //!
5
+ //! Soundness rule: a wrong "constant" bakes stale content into production HTML
6
+ //! (a correctness bug), while a wrong "dynamic" merely forgoes the optimization
7
+ //! (a brief FOUC, same as today). So every uncertain case resolves to **dynamic**.
8
+ //!
9
+ //! A key is reported constant only when ALL of these hold:
10
+ //! 1. Its initial value is a primitive (string / number / bool / null). Objects
11
+ //! and arrays are never constant — their contents can be mutated through a
12
+ //! value alias (`const a = $.items; a.push(x)`) that a `$.key` scan cannot
13
+ //! see, so they are conservatively excluded.
14
+ //! 2. No write to `$.key` anywhere across the scanned sources — assignment,
15
+ //! compound assignment, `++`/`--`, `delete`, or a nested `$.key.x = …`.
16
+ //!
17
+ //! Any of these "escape" signals discards the whole optimization (every key is
18
+ //! treated dynamic), because they could write a key we cannot pin down:
19
+ //! - aliasing `$` itself (`const x = $` / `x = $`),
20
+ //! - reflective writes (`Object.assign($, …)` / `Object.defineProperty($, …)`),
21
+ //! - a source that fails to parse (we cannot prove it is write-free).
22
+ //!
23
+ //! The `$` access split mirrors Vibe's own convention: `$.key` and `$['key']`
24
+ //! (string literal) address GLOBAL state, while `$[expr]` with a non-literal key
25
+ //! is the component-state-by-id pattern (`const id = component(s); $[id].x = …`)
26
+ //! whose key is a generated component id, never a global key — so a non-literal
27
+ //! computed write is ignored, not a bail.
28
+ //!
29
+ //! Passing `$` as a plain argument (`derive($, …)`) is treated as a read, not an
30
+ //! escape — the vibe convention is to write state through direct `$.key = …`
31
+ //! member assignments, and bailing on every `f($)` would disable the optimization
32
+ //! for any real app.
33
+
34
+ use swc_common::{sync::Lrc, FileName, SourceMap};
35
+ use swc_ecma_ast::*;
36
+ use swc_ecma_parser::{EsSyntax, Parser, StringInput, Syntax};
37
+ use swc_ecma_visit::{Visit, VisitWith};
38
+
39
+ use serde_json::{Map, Value};
40
+ use std::collections::HashSet;
41
+
42
+ /// Classify which `initial_state` keys are safe to treat as compile-time
43
+ /// constants, given every JS source in the app (module files, inline `<script>`
44
+ /// bodies, and HTML `on*` handler bodies). See module docs for the rules.
45
+ pub fn classify_constant_keys(
46
+ initial_state: &Map<String, Value>,
47
+ sources: &[String],
48
+ ) -> HashSet<String> {
49
+ let mut written: HashSet<String> = HashSet::new();
50
+
51
+ for src in sources {
52
+ let module = match parse(src) {
53
+ Some(m) => m,
54
+ // Cannot prove a source is write-free → forfeit the whole optimization.
55
+ None => return HashSet::new(),
56
+ };
57
+ let mut visitor = WriteVisitor::default();
58
+ module.visit_with(&mut visitor);
59
+ if visitor.bail {
60
+ return HashSet::new();
61
+ }
62
+ written.extend(visitor.written);
63
+ }
64
+
65
+ initial_state
66
+ .iter()
67
+ .filter(|(key, value)| is_primitive(value) && !written.contains(key.as_str()))
68
+ .map(|(key, _)| key.clone())
69
+ .collect()
70
+ }
71
+
72
+ /// True if `src` parses as JavaScript. Used to drop heuristically-extracted
73
+ /// HTML attribute values that aren't actually event-handler code, so that genuine
74
+ /// unparseable JS sources can still trigger the conservative bail.
75
+ pub fn parses_as_js(src: &str) -> bool {
76
+ parse(src).is_some()
77
+ }
78
+
79
+ fn is_primitive(value: &Value) -> bool {
80
+ matches!(
81
+ value,
82
+ Value::String(_) | Value::Number(_) | Value::Bool(_) | Value::Null
83
+ )
84
+ }
85
+
86
+ /// Parse a source as a module. Handler bodies can contain top-level `return`
87
+ /// (`onclick="if (x) return; f()"`), which is illegal at module top level, so a
88
+ /// failed parse is retried wrapped in a function before giving up — writes are
89
+ /// still found inside the wrapper, while genuinely broken sources fall through.
90
+ fn parse(src: &str) -> Option<Module> {
91
+ parse_module(src).or_else(|| parse_module(&format!("function __vibe_wrap__(){{\n{}\n}}", src)))
92
+ }
93
+
94
+ fn parse_module(code: &str) -> Option<Module> {
95
+ let cm: Lrc<SourceMap> = Default::default();
96
+ let fm = cm.new_source_file(Lrc::new(FileName::Anon), code.to_string());
97
+ let syntax = Syntax::Es(EsSyntax {
98
+ jsx: false,
99
+ decorators: true,
100
+ ..Default::default()
101
+ });
102
+ let input = StringInput::new(&fm.src, fm.start_pos, fm.end_pos);
103
+ let mut parser = Parser::new(syntax, input, None);
104
+ parser.parse_module().ok()
105
+ }
106
+
107
+ fn is_dollar(expr: &Expr) -> bool {
108
+ matches!(expr, Expr::Ident(id) if id.sym.to_string() == "$")
109
+ }
110
+
111
+ /// What a write target rooted at `$` resolves to.
112
+ enum DollarTarget {
113
+ /// `$.key`, `$.key.x…`, or `$['key']` — the top-level GLOBAL state key touched.
114
+ Key(String),
115
+ /// `$[expr]` with a non-literal key — Vibe's component-state-by-id access,
116
+ /// never a global key, so irrelevant to global-constant analysis.
117
+ ComponentState,
118
+ /// Not rooted at `$`.
119
+ None,
120
+ }
121
+
122
+ /// Walk a member chain to its base. If the base is the `$` identifier, return
123
+ /// the FIRST property after it (the top-level state key). `$.a.b.c` → `a`.
124
+ fn dollar_target(member: &MemberExpr) -> DollarTarget {
125
+ match &*member.obj {
126
+ Expr::Ident(id) if id.sym.to_string() == "$" => match &member.prop {
127
+ MemberProp::Ident(name) => DollarTarget::Key(name.sym.to_string()),
128
+ MemberProp::Computed(c) => match &*c.expr {
129
+ Expr::Lit(Lit::Str(s)) => DollarTarget::Key(s.value.to_string()),
130
+ _ => DollarTarget::ComponentState,
131
+ },
132
+ MemberProp::PrivateName(_) => DollarTarget::None,
133
+ },
134
+ Expr::Member(inner) => dollar_target(inner),
135
+ Expr::Paren(p) => match &*p.expr {
136
+ Expr::Member(inner) => dollar_target(inner),
137
+ _ => DollarTarget::None,
138
+ },
139
+ _ => DollarTarget::None,
140
+ }
141
+ }
142
+
143
+ #[derive(Default)]
144
+ struct WriteVisitor {
145
+ written: HashSet<String>,
146
+ bail: bool,
147
+ }
148
+
149
+ impl WriteVisitor {
150
+ /// Record a write whose target is (or descends from) `$`.
151
+ fn record_member_write(&mut self, member: &MemberExpr) {
152
+ match dollar_target(member) {
153
+ DollarTarget::Key(k) => {
154
+ self.written.insert(k);
155
+ }
156
+ // Component-state-by-id write — irrelevant to global keys.
157
+ DollarTarget::ComponentState => {}
158
+ DollarTarget::None => {}
159
+ }
160
+ }
161
+ }
162
+
163
+ /// Detects any use of the bare `$` identifier within a subtree.
164
+ #[derive(Default)]
165
+ struct DollarUseFinder {
166
+ found: bool,
167
+ }
168
+ impl Visit for DollarUseFinder {
169
+ fn visit_ident(&mut self, id: &Ident) {
170
+ if id.sym.to_string() == "$" {
171
+ self.found = true;
172
+ }
173
+ }
174
+ }
175
+
176
+ impl Visit for WriteVisitor {
177
+ fn visit_assign_expr(&mut self, n: &AssignExpr) {
178
+ match &n.left {
179
+ AssignTarget::Simple(SimpleAssignTarget::Member(m)) => self.record_member_write(m),
180
+ // `($.k) = …`
181
+ AssignTarget::Simple(SimpleAssignTarget::Paren(p)) => {
182
+ if let Expr::Member(m) = &*p.expr {
183
+ self.record_member_write(m);
184
+ }
185
+ }
186
+ // Reassigning `$` itself (`$ = …`) replaces the whole state object.
187
+ AssignTarget::Simple(SimpleAssignTarget::Ident(id)) => {
188
+ if id.id.sym.to_string() == "$" {
189
+ self.bail = true;
190
+ }
191
+ }
192
+ // Destructuring assignment that writes through `$` (`[$.k] = …`).
193
+ AssignTarget::Pat(pat) => {
194
+ let mut finder = DollarUseFinder::default();
195
+ pat.visit_with(&mut finder);
196
+ if finder.found {
197
+ self.bail = true;
198
+ }
199
+ }
200
+ _ => {}
201
+ }
202
+ // Aliasing: `x = $` lets later `x.key = …` writes escape our scan.
203
+ if is_dollar(&n.right) {
204
+ self.bail = true;
205
+ }
206
+ n.visit_children_with(self);
207
+ }
208
+
209
+ fn visit_var_declarator(&mut self, n: &VarDeclarator) {
210
+ // `const x = $` — same aliasing escape as `x = $`.
211
+ if let Some(init) = &n.init {
212
+ if is_dollar(init) {
213
+ self.bail = true;
214
+ }
215
+ }
216
+ n.visit_children_with(self);
217
+ }
218
+
219
+ fn visit_update_expr(&mut self, n: &UpdateExpr) {
220
+ // `$.key++`, `--$.key`
221
+ if let Expr::Member(m) = &*n.arg {
222
+ self.record_member_write(m);
223
+ }
224
+ n.visit_children_with(self);
225
+ }
226
+
227
+ fn visit_unary_expr(&mut self, n: &UnaryExpr) {
228
+ // `delete $.key`
229
+ if matches!(n.op, UnaryOp::Delete) {
230
+ if let Expr::Member(m) = &*n.arg {
231
+ self.record_member_write(m);
232
+ }
233
+ }
234
+ n.visit_children_with(self);
235
+ }
236
+
237
+ fn visit_call_expr(&mut self, n: &CallExpr) {
238
+ // Reflective writes: Object.assign($, …) / Object.defineProperty($, …) /
239
+ // Reflect.set($, …) and friends could write any key.
240
+ if let Callee::Expr(callee) = &n.callee {
241
+ if let Expr::Member(m) = &**callee {
242
+ if let Expr::Ident(obj) = &*m.obj {
243
+ let obj = obj.sym.to_string();
244
+ if let MemberProp::Ident(method) = &m.prop {
245
+ let method = method.sym.to_string();
246
+ let reflective_write = matches!(
247
+ (obj.as_str(), method.as_str()),
248
+ ("Object", "assign")
249
+ | ("Object", "defineProperty")
250
+ | ("Object", "defineProperties")
251
+ | ("Object", "setPrototypeOf")
252
+ | ("Reflect", "set")
253
+ | ("Reflect", "defineProperty")
254
+ | ("Reflect", "deleteProperty")
255
+ | ("Reflect", "setPrototypeOf")
256
+ );
257
+ if reflective_write {
258
+ if let Some(first) = n.args.first() {
259
+ if is_dollar(&first.expr) {
260
+ self.bail = true;
261
+ }
262
+ }
263
+ }
264
+ }
265
+ }
266
+ }
267
+ }
268
+ n.visit_children_with(self);
269
+ }
270
+ }
271
+
272
+ #[cfg(test)]
273
+ mod tests {
274
+ use super::*;
275
+ use serde_json::json;
276
+
277
+ fn state(pairs: &[(&str, Value)]) -> Map<String, Value> {
278
+ pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
279
+ }
280
+
281
+ #[test]
282
+ fn primitive_never_written_is_constant() {
283
+ let s = state(&[("version", json!("0.1.5"))]);
284
+ let got = classify_constant_keys(&s, &["console.log($.version);".to_string()]);
285
+ assert!(got.contains("version"));
286
+ }
287
+
288
+ #[test]
289
+ fn direct_reassignment_is_dynamic() {
290
+ let s = state(&[("coins", json!(400)), ("version", json!("0.1.5"))]);
291
+ let got = classify_constant_keys(&s, &["$.coins = 500;".to_string()]);
292
+ assert!(!got.contains("coins"));
293
+ assert!(got.contains("version"));
294
+ }
295
+
296
+ #[test]
297
+ fn compound_and_update_are_dynamic() {
298
+ let s = state(&[("a", json!(1)), ("b", json!(1)), ("c", json!(1))]);
299
+ let got = classify_constant_keys(&s, &["$.a += 1; $.b++; --$.c;".to_string()]);
300
+ assert!(got.is_empty());
301
+ }
302
+
303
+ #[test]
304
+ fn computed_string_literal_write_is_dynamic() {
305
+ let s = state(&[("coins", json!(400)), ("version", json!("v"))]);
306
+ let got = classify_constant_keys(&s, &["$['coins'] = 1;".to_string()]);
307
+ assert!(!got.contains("coins"));
308
+ assert!(got.contains("version"));
309
+ }
310
+
311
+ #[test]
312
+ fn delete_is_dynamic() {
313
+ let s = state(&[("x", json!(1))]);
314
+ let got = classify_constant_keys(&s, &["delete $.x;".to_string()]);
315
+ assert!(!got.contains("x"));
316
+ }
317
+
318
+ #[test]
319
+ fn nested_property_write_marks_top_key_dynamic() {
320
+ let s = state(&[("settings", json!({ "a": 1 })), ("version", json!("v"))]);
321
+ let got = classify_constant_keys(&s, &["$.settings.theme = 'dark';".to_string()]);
322
+ assert!(!got.contains("settings"));
323
+ assert!(got.contains("version"));
324
+ }
325
+
326
+ #[test]
327
+ fn objects_and_arrays_are_never_constant() {
328
+ let s = state(&[
329
+ ("characters", json!([])),
330
+ ("clock", json!({ "now": 0 })),
331
+ ("version", json!("v")),
332
+ ]);
333
+ let got = classify_constant_keys(&s, &["// no writes".to_string()]);
334
+ assert!(!got.contains("characters"));
335
+ assert!(!got.contains("clock"));
336
+ assert!(got.contains("version"));
337
+ }
338
+
339
+ #[test]
340
+ fn aliasing_dollar_bails_everything() {
341
+ let s = state(&[("version", json!("v")), ("coins", json!(1))]);
342
+ let got = classify_constant_keys(&s, &["const x = $; x.coins = 5;".to_string()]);
343
+ assert!(got.is_empty());
344
+ }
345
+
346
+ #[test]
347
+ fn reassigning_dollar_bails_everything() {
348
+ let s = state(&[("version", json!("v"))]);
349
+ let got = classify_constant_keys(&s, &["let y; y = $;".to_string()]);
350
+ assert!(got.is_empty());
351
+ }
352
+
353
+ #[test]
354
+ fn computed_component_state_write_is_ignored() {
355
+ // `$[id]` / `$[id].x = …` is Vibe's component-state-by-id pattern (id is a
356
+ // generated component id, never a global key), so it must not bail.
357
+ let s = state(&[("version", json!("v"))]);
358
+ let got = classify_constant_keys(
359
+ &s,
360
+ &["const id = component({ open: false }); $[id].open = true;".to_string()],
361
+ );
362
+ assert!(got.contains("version"));
363
+ }
364
+
365
+ #[test]
366
+ fn computed_string_literal_global_write_is_dynamic() {
367
+ // A *literal* computed key IS a global-key write and must mark it dynamic.
368
+ let s = state(&[("coins", json!(1)), ("version", json!("v"))]);
369
+ let got = classify_constant_keys(&s, &["$['coins'] = 5;".to_string()]);
370
+ assert!(!got.contains("coins"));
371
+ assert!(got.contains("version"));
372
+ }
373
+
374
+ #[test]
375
+ fn object_assign_on_dollar_bails_everything() {
376
+ let s = state(&[("version", json!("v"))]);
377
+ let got = classify_constant_keys(&s, &["Object.assign($, { coins: 5 });".to_string()]);
378
+ assert!(got.is_empty());
379
+ }
380
+
381
+ #[test]
382
+ fn write_in_nested_scope_is_detected() {
383
+ let s = state(&[("token", json!(null)), ("version", json!("v"))]);
384
+ let src = "function f(){ if (true) { $.token = undefined; } } const g = () => $.version;";
385
+ let got = classify_constant_keys(&s, &[src.to_string()]);
386
+ assert!(!got.contains("token"));
387
+ assert!(got.contains("version"));
388
+ }
389
+
390
+ #[test]
391
+ fn write_across_multiple_sources() {
392
+ let s = state(&[("a", json!(1)), ("b", json!(2))]);
393
+ let srcs = ["$.a = 1;".to_string(), "console.log($.b);".to_string()];
394
+ let got = classify_constant_keys(&s, &srcs);
395
+ assert!(!got.contains("a"));
396
+ assert!(got.contains("b"));
397
+ }
398
+
399
+ #[test]
400
+ fn reads_do_not_mark_dynamic() {
401
+ let s = state(&[("version", json!("v"))]);
402
+ let src = "const x = $.version; if ($.version === 'v') foo($.version);";
403
+ let got = classify_constant_keys(&s, &[src.to_string()]);
404
+ assert!(got.contains("version"));
405
+ }
406
+
407
+ #[test]
408
+ fn passing_dollar_as_argument_does_not_bail() {
409
+ let s = state(&[("version", json!("v"))]);
410
+ let src = "dataInspectorState($, paths); render($);";
411
+ let got = classify_constant_keys(&s, &[src.to_string()]);
412
+ assert!(got.contains("version"));
413
+ }
414
+
415
+ #[test]
416
+ fn unparseable_source_bails_everything() {
417
+ let s = state(&[("version", json!("v"))]);
418
+ let got = classify_constant_keys(&s, &["this is (((not valid @@@".to_string()]);
419
+ assert!(got.is_empty());
420
+ }
421
+
422
+ #[test]
423
+ fn handler_body_write_is_detected() {
424
+ let s = state(&[("overlay", json!({})), ("version", json!("v"))]);
425
+ let got = classify_constant_keys(
426
+ &s,
427
+ &["event.preventDefault(); $.overlay = { name: 'x' };".to_string()],
428
+ );
429
+ assert!(got.contains("version"));
430
+ assert!(!got.contains("overlay"));
431
+ }
432
+
433
+ #[test]
434
+ fn realistic_app_state_subset() {
435
+ // Mirrors the battle-brawlers global state: only `version` survives.
436
+ let s = state(&[
437
+ ("version", json!("0.1.5")),
438
+ ("coins", json!(400)),
439
+ ("characters", json!([])),
440
+ ("token", json!(null)),
441
+ ("settings", json!({ "darkMode": false })),
442
+ ]);
443
+ let srcs = [
444
+ "if (email) $.email = email; $.canTest = false;".to_string(),
445
+ "if (gameState.characters) $.characters = gameState.characters;".to_string(),
446
+ "if (gameState.coins !== undefined) $.coins = gameState.coins;".to_string(),
447
+ "$.token = undefined; const v = $.version;".to_string(),
448
+ ];
449
+ let got = classify_constant_keys(&s, &srcs);
450
+ assert!(got.contains("version"));
451
+ assert!(!got.contains("coins"));
452
+ assert!(!got.contains("characters"));
453
+ assert!(!got.contains("token"));
454
+ assert!(!got.contains("settings"));
455
+ }
456
+ }
@@ -1,7 +1,20 @@
1
1
  use regex::{Regex, Captures};
2
- use serde_json::Value;
2
+ use serde_json::{Map, Value};
3
3
  use rquickjs::{Context, Runtime};
4
- use std::collections::HashSet;
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
+ }
5
18
 
6
19
  // Elements that should not have reactive bindings processed
7
20
  // Matches runtime/constants.js NON_REACTIVE_ELEMENTS
@@ -53,10 +66,23 @@ pub struct ValueStamper<'a> {
53
66
  _runtime: Runtime, // Must be kept alive for context to work
54
67
  context: Context,
55
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>,
56
74
  }
57
75
 
58
76
  impl<'a> ValueStamper<'a> {
59
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> {
60
86
  let runtime = Runtime::new().map_err(|e| format!("Failed to create QuickJS runtime: {:?}", e))?;
61
87
  let context = Context::full(&runtime).map_err(|e| format!("Failed to create QuickJS context: {:?}", e))?;
62
88
 
@@ -76,12 +102,18 @@ impl<'a> ValueStamper<'a> {
76
102
  Ok(())
77
103
  })?;
78
104
 
105
+ let constant_texts = constants
106
+ .iter()
107
+ .map(|(key, value)| (key.clone(), primitive_to_text(value)))
108
+ .collect();
109
+
79
110
  Ok(Self {
80
111
  state,
81
112
  binding_regex: Regex::new(r"@\[((?:[^\[\]]|\[[^\]]*\])+)\]").unwrap(),
82
113
  _runtime: runtime,
83
114
  context,
84
115
  components_as_is,
116
+ constant_texts,
85
117
  })
86
118
  }
87
119
 
@@ -164,9 +196,17 @@ impl<'a> ValueStamper<'a> {
164
196
  // Stamp the binding
165
197
  let caps = self.binding_regex.captures(binding_match.as_str()).unwrap();
166
198
  let expr = &caps[1];
167
- let stamped = self.eval_expression(expr)
168
- .unwrap_or_else(|| binding_match.as_str().to_string());
169
- result.push_str(&stamped);
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
+ }
170
210
  }
171
211
 
172
212
  last_pos = match_end;
@@ -226,7 +266,11 @@ impl<'a> ValueStamper<'a> {
226
266
  }
227
267
 
228
268
  fn render_iterations_recursive(&self, html: String) -> Result<String, String> {
229
- let each_start = Regex::new(r"<!--\s*each\s+(\w+(?:\.\w+)*)\s+as\s+(\w+)(?:\s*,\s*(\w+))?\s*-->").unwrap();
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();
230
274
 
231
275
  let mut result = html.clone();
232
276
  let mut search_pos = 0;
@@ -241,20 +285,19 @@ impl<'a> ValueStamper<'a> {
241
285
  let item_alias = captures.get(2).unwrap().as_str().to_string();
242
286
  let index_alias = captures.get(3).map(|m| m.as_str().to_string()).unwrap_or_else(|| "index".to_string());
243
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();
244
289
 
245
290
  // Find matching <!-- /each --> using depth counting
246
291
  if let Some((end_pos, end_after)) = self.find_matching_end(&result, template_start) {
247
292
  let template = result[template_start..end_pos].to_string();
248
293
 
249
- // Get array from state using QuickJS
250
- let array = match self.eval_array_path(&array_path) {
251
- Some(arr) => arr,
252
- None => {
253
- // Array not found, skip this iteration
254
- search_pos = end_after;
255
- continue;
256
- }
257
- };
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();
258
301
 
259
302
  // Render each item
260
303
  let mut rendered_items = Vec::new();
@@ -298,10 +341,11 @@ impl<'a> ValueStamper<'a> {
298
341
  };
299
342
 
300
343
  let replacement = format!(
301
- "<!-- each {} as {}{} -->{}<!-- /each -->",
344
+ "<!-- each {} as {}{}{} -->{}<!-- /each -->",
302
345
  array_path,
303
346
  item_alias,
304
347
  index_part,
348
+ key_part,
305
349
  rendered_items.join("")
306
350
  );
307
351
 
@@ -767,6 +811,38 @@ mod tests {
767
811
  assert_eq!(result, r#"<!-- each items as item, idx --><span>[0] a</span><span>[1] b</span><span>[2] c</span><!-- /each -->"#);
768
812
  }
769
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
+
770
846
  #[test]
771
847
  fn cleanup_boolean_attributes_false() {
772
848
  let state = json!({ "buttonDisabled": false });
@@ -806,4 +882,40 @@ mod tests {
806
882
  // disabled and required should be removed, readonly should be present with empty value
807
883
  assert_eq!(result, r#"<input readonly="">"#);
808
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
+ }
809
921
  }