@ape-egg/vibe 2.0.0 → 2.1.1

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.
@@ -60,6 +60,16 @@ fn extract_and_compile_iterations(html: &str) -> HashMap<String, CompiledIterati
60
60
  continue;
61
61
  }
62
62
 
63
+ // Raw-HTML bindings can't ride a template literal: a batch function
64
+ // interpolates RawHtml via toString(), which escapes. Skip the batch
65
+ // function so the runtime renders these rows through clone+hydrate —
66
+ // the one place that implements $.unsafe semantics (innerHTML +
67
+ // inert subtree).
68
+ if template_html.contains("$.unsafe(") {
69
+ search_start = end_after;
70
+ continue;
71
+ }
72
+
63
73
  // Generate hash for this template
64
74
  let hash = generate_template_hash(template_html);
65
75
 
@@ -113,6 +123,54 @@ fn generate_template_hash(template: &str) -> String {
113
123
  format!("iter_{:x}", hash)
114
124
  }
115
125
 
126
+ /// DOM element properties — set on the element, kept as value attributes in
127
+ /// batch output. Mirrors DOM_PROPERTIES in runtime/constants.js.
128
+ const DOM_PROPERTIES: &[&str] = &["value", "checked", "selected"];
129
+
130
+ /// Attributes that keep their string value verbatim (never boolean-coerced).
131
+ /// Mirrors VALUE_ATTRS in runtime/constants.js — the two lists must stay in
132
+ /// lockstep or batch and clone paths render different attributes.
133
+ const VALUE_ATTRS: &[&str] = &[
134
+ "class", "style", "id", "title", "lang", "dir", "tabindex", "accesskey",
135
+ "slot", "part", "is", "nonce", "popover", "anchor",
136
+ "contenteditable", "draggable", "spellcheck", "translate",
137
+ "autocapitalize", "inputmode", "enterkeyhint", "virtualkeyboardpolicy",
138
+ "href", "src", "action", "cite", "data", "poster", "srcset",
139
+ "imagesrcset", "formaction", "ping", "usemap", "manifest", "codebase",
140
+ "name", "type", "value", "placeholder", "pattern", "min", "max", "step",
141
+ "minlength", "maxlength", "size", "accept", "autocomplete", "list",
142
+ "form", "formmethod", "formtarget", "formenctype", "wrap", "method",
143
+ "enctype", "for", "dirname",
144
+ "alt", "label", "summary", "abbr",
145
+ "width", "height", "cols", "rows", "span", "rowspan", "colspan",
146
+ "low", "high", "optimum",
147
+ "target", "rel", "hreflang", "download", "as", "media", "charset",
148
+ "crossorigin", "integrity", "loading", "decoding", "fetchpriority",
149
+ "referrerpolicy", "blocking", "imagesizes", "sizes",
150
+ "preload", "kind", "srclang",
151
+ "content", "http-equiv",
152
+ "sandbox", "allow", "srcdoc", "credentialless",
153
+ "headers", "scope",
154
+ "datetime",
155
+ "coords", "shape",
156
+ ];
157
+
158
+ /// Whether an attribute keeps its string value (vs boolean coercion).
159
+ /// Mirrors isValueStyleAttr in runtime/iterate.js.
160
+ fn is_value_style_attr(name: &str) -> bool {
161
+ VALUE_ATTRS.contains(&name)
162
+ || name.starts_with("data-")
163
+ || name.starts_with("aria-")
164
+ || name.starts_with("on")
165
+ }
166
+
167
+ /// Escape a static text chunk for a JS template-literal context.
168
+ fn escape_tpl_text(text: &str) -> String {
169
+ text.replace('\\', "\\\\")
170
+ .replace('`', "\\`")
171
+ .replace("${", "\\${")
172
+ }
173
+
116
174
  /// Compile template HTML to a batch function string
117
175
  /// Supports nested <!-- each --> blocks
118
176
  fn compile_template_to_batch_fn(
@@ -120,134 +178,120 @@ fn compile_template_to_batch_fn(
120
178
  item_alias: &str,
121
179
  index_alias: &str,
122
180
  ) -> String {
123
- // Process nested iterations first
124
- let processed_template = process_nested_iterations(template);
125
-
126
- // Replace @[expr] with ${expr}
127
- let binding_re = Regex::new(r"@\[([^\]]+)\]").unwrap();
128
- let with_bindings = binding_re.replace_all(&processed_template, |caps: &regex::Captures| {
129
- format!("${{{}}}", &caps[1])
130
- });
131
-
132
- // Fix boolean attributes: remove ="" from attributes to match CSS selectors
133
- // Converts: <div attr=""> to <div attr>
181
+ // Normalize static boolean attributes: <div attr=""> → <div attr>
182
+ // (matches CSS attribute selectors). Runs on raw template text, before
183
+ // emission — emitted JS below also contains attr="" inside string
184
+ // literals, which must not be touched.
134
185
  let boolean_attr_re = Regex::new(r#"(\w+)="""#).unwrap();
135
- let with_boolean_attrs = boolean_attr_re.replace_all(&with_bindings, "$1");
186
+ let cleaned = boolean_attr_re.replace_all(template, "$1");
136
187
 
137
- // Escape backslashes only (backticks are fine in JSON strings)
138
- let escaped = with_boolean_attrs
139
- .replace('\\', "\\\\");
188
+ let body = emit_template_literal(&cleaned);
140
189
 
141
- // Generate batch function
142
190
  format!(
143
- r#"(arr, $) => {{ let html = ''; const len = arr.length; for (let {index} = 0; {index} < len; {index}++) {{ const {item} = arr[{index}]; html += `{template}`; }} return html; }}"#,
191
+ r#"(arr, $) => {{ let html = ''; const len = arr.length; for (let {index} = 0; {index} < len; {index}++) {{ const {item} = arr[{index}]; html += `{body}`; }} return html; }}"#,
144
192
  index = index_alias,
145
193
  item = item_alias,
146
- template = escaped
194
+ body = body
147
195
  )
148
196
  }
149
197
 
150
- /// Process nested <!-- each --> blocks recursively
151
- /// Replaces nested iterations with inline loop code
152
- fn process_nested_iterations(template: &str) -> String {
198
+ /// Emit the body of a JS template literal for a template chunk:
199
+ /// - static text escaped for template-literal context
200
+ /// - attribute pure bindings classified like hydrate.js / compileBatchFn:
201
+ /// DOM properties and value-style attributes keep `attr="${expr}"`,
202
+ /// boolean-coerced attributes become `${(expr) ? ' attr=""' : ''}` so the
203
+ /// attribute is ABSENT when falsy and present-empty when truthy
204
+ /// - remaining @[expr] bindings (text content, partial attribute values)
205
+ /// become `${expr}` interpolations
206
+ /// - nested <!-- each --> blocks become inline IIFE loops, recursively
207
+ ///
208
+ /// Anything the runtime clone+hydrate path renders, this output has to render
209
+ /// identically — it is the compiled twin of compileBatchFn in runtime/iterate.js.
210
+ fn emit_template_literal(template: &str) -> String {
153
211
  let each_re = Regex::new(r"<!--\s*each\s+([^\s]+)\s+as\s+([^\s,]+)(?:\s*,\s*([^\s]+))?\s*-->").unwrap();
154
212
  let end_re = Regex::new(r"<!--\s*/each\s*-->").unwrap();
155
-
156
- let mut result = template.to_string();
157
- let mut replacements = Vec::new();
158
-
159
- // Find all nested iterations
160
- let mut search_start = 0;
161
- while let Some(start_match) = each_re.find_at(&result, search_start) {
162
- let captures = each_re.captures(&result[start_match.start()..]).unwrap();
163
- let array_path = captures.get(1).unwrap().as_str();
164
- let item_alias = captures.get(2).unwrap().as_str();
165
- let index_alias = captures.get(3).map(|m| m.as_str()).unwrap_or("index");
166
-
167
- let template_start = start_match.end();
168
-
169
- // Find matching <!-- /each --> using depth counting
170
- if let Some((end_pos, _)) = find_matching_each_end(&result, template_start) {
171
- let inner_template = &result[template_start..end_pos];
172
-
173
- // Recursively process inner template
174
- let processed_inner = process_nested_iterations(inner_template);
175
-
176
- // For nested iterations, convert to string concatenation
177
- // Uses JSON string escaping which is valid JavaScript
178
- let inner_concat_code = convert_to_string_concat(&processed_inner, item_alias, index_alias, array_path);
179
-
180
- let nested_code = format!("${{(() => {{ let inner = ''; const len_{idx} = {arr}.length; for (let {idx} = 0; {idx} < len_{idx}; {idx}++) {{ const {item} = {arr}[{idx}]; {code} }} return inner; }})()}}",
181
- idx = index_alias,
182
- arr = array_path,
183
- item = item_alias,
184
- code = inner_concat_code
185
- );
186
-
187
- // Store replacement (from start to end including comments)
188
- let end_match = end_re.find_at(&result, end_pos).unwrap();
189
- replacements.push((start_match.start(), end_match.end(), nested_code));
190
-
191
- search_start = end_match.end();
192
- } else {
193
- break;
194
- }
195
- }
196
-
197
- // Apply replacements in reverse order to maintain positions
198
- for (start, end, replacement) in replacements.iter().rev() {
199
- result.replace_range(*start..*end, replacement);
200
- }
201
-
202
- result
203
- }
204
-
205
- /// Convert template to string concatenation code using template literals
206
- /// Parses @[expr] and converts to: inner += `text${expr}more text`;
207
- /// Template literals properly handle newlines without escaping
208
- /// NOTE: Backslashes and ${ need escaping, but backticks don't (they'll be in JSON)
209
- fn convert_to_string_concat(template: &str, _item_alias: &str, _index_alias: &str, _array_path: &str) -> String {
213
+ let attr_binding_re = Regex::new(r#"(\s)([\w-]+)="@\[([^\]]+)\]""#).unwrap();
210
214
  let binding_re = Regex::new(r"@\[([^\]]+)\]").unwrap();
211
215
 
212
- // Build template literal with ${} expressions
213
- let mut result = String::from("inner += `");
214
- let mut last_end = 0;
215
-
216
- for cap in binding_re.captures_iter(template) {
217
- let match_start = cap.get(0).unwrap().start();
218
- let match_end = cap.get(0).unwrap().end();
219
- let expr = &cap[1];
220
-
221
- // Add text before this binding
222
- if match_start > last_end {
223
- let text = &template[last_end..match_start];
224
- // For template literals: only escape backslashes and ${ sequences
225
- // Don't escape backticks - they're fine in JSON and we want them as-is
226
- let escaped = text
227
- .replace('\\', "\\\\") // Escape backslashes
228
- .replace("${", "\\${"); // Escape template literal expression markers
229
- result.push_str(&escaped);
216
+ let mut out = String::new();
217
+ let mut pos = 0;
218
+
219
+ while pos < template.len() {
220
+ let next_each = each_re.find_at(template, pos);
221
+ let next_attr = attr_binding_re.find_at(template, pos);
222
+ let next_binding = binding_re.find_at(template, pos);
223
+
224
+ // Earliest match wins; attr-binding outranks plain binding at the same
225
+ // region (the plain regex would match inside the attr form).
226
+ let candidates = [
227
+ next_each.map(|m| (m.start(), 0u8)),
228
+ next_attr.map(|m| (m.start(), 1u8)),
229
+ next_binding.map(|m| (m.start(), 2u8)),
230
+ ];
231
+ let Some(&(start, kind)) = candidates
232
+ .iter()
233
+ .flatten()
234
+ .min_by_key(|(s, k)| (*s, *k))
235
+ else {
236
+ out.push_str(&escape_tpl_text(&template[pos..]));
237
+ break;
238
+ };
239
+
240
+ out.push_str(&escape_tpl_text(&template[pos..start]));
241
+
242
+ match kind {
243
+ 0 => {
244
+ // Nested <!-- each --> → inline IIFE loop
245
+ let m = next_each.unwrap();
246
+ let caps = each_re.captures(&template[m.start()..]).unwrap();
247
+ let arr = caps.get(1).unwrap().as_str();
248
+ let item = caps.get(2).unwrap().as_str();
249
+ let idx = caps.get(3).map(|c| c.as_str()).unwrap_or("index");
250
+
251
+ if let Some((end_pos, _)) = find_matching_each_end(template, m.end()) {
252
+ let inner = emit_template_literal(&template[m.end()..end_pos]);
253
+ out.push_str(&format!(
254
+ "${{(() => {{ let inner = ''; const len_{idx} = {arr}.length; for (let {idx} = 0; {idx} < len_{idx}; {idx}++) {{ const {item} = {arr}[{idx}]; inner += `{inner_body}`; }} return inner; }})()}}",
255
+ idx = idx,
256
+ arr = arr,
257
+ item = item,
258
+ inner_body = inner
259
+ ));
260
+ let end_match = end_re.find_at(template, end_pos).unwrap();
261
+ pos = end_match.end();
262
+ } else {
263
+ // Unbalanced each — emit as text and move on
264
+ out.push_str(&escape_tpl_text(&template[m.start()..m.end()]));
265
+ pos = m.end();
266
+ }
267
+ }
268
+ 1 => {
269
+ // Attribute pure binding: attr="@[expr]"
270
+ let m = next_attr.unwrap();
271
+ let caps = attr_binding_re.captures(&template[m.start()..]).unwrap();
272
+ let ws = caps.get(1).unwrap().as_str();
273
+ let name = caps.get(2).unwrap().as_str();
274
+ let expr = caps.get(3).unwrap().as_str();
275
+ let name_lc = name.to_lowercase();
276
+
277
+ if DOM_PROPERTIES.contains(&name_lc.as_str()) || is_value_style_attr(&name_lc) {
278
+ out.push_str(&format!("{ws}{name}=\"${{{expr}}}\""));
279
+ } else {
280
+ out.push_str(&format!("${{({expr}) ? ' {name}=\"\"' : ''}}"));
281
+ }
282
+ pos = m.end();
283
+ }
284
+ _ => {
285
+ // Plain binding: text content or partial attribute value
286
+ let m = next_binding.unwrap();
287
+ let caps = binding_re.captures(&template[m.start()..]).unwrap();
288
+ out.push_str(&format!("${{{}}}", caps.get(1).unwrap().as_str()));
289
+ pos = m.end();
290
+ }
230
291
  }
231
-
232
- // Add expression
233
- result.push_str("${");
234
- result.push_str(expr);
235
- result.push_str("}");
236
-
237
- last_end = match_end;
238
- }
239
-
240
- // Add remaining text
241
- if last_end < template.len() {
242
- let text = &template[last_end..];
243
- let escaped = text
244
- .replace('\\', "\\\\")
245
- .replace("${", "\\${");
246
- result.push_str(&escaped);
247
292
  }
248
293
 
249
- result.push_str("`;");
250
- result
294
+ out
251
295
  }
252
296
 
253
297
  /// Find matching <!-- /each --> comment using depth counting
@@ -323,6 +367,38 @@ mod tests {
323
367
  assert_eq!(compiled.index_alias, "idx");
324
368
  }
325
369
 
370
+ #[test]
371
+ fn test_boolean_attr_binding_emits_conditional_presence() {
372
+ let html = r#"
373
+ <!-- each items as item -->
374
+ <attr-target open="@[flag]" href="@[item.url]" value="@[item.v]"></attr-target>
375
+ <!-- /each -->
376
+ "#;
377
+
378
+ let opts = build_iteration_optimizations(html);
379
+ let iterations = &opts.unwrap().iterations;
380
+ let (_, compiled) = iterations.iter().next().unwrap();
381
+
382
+ // Boolean-coerced attribute: absent when falsy, present-empty when truthy
383
+ assert!(compiled.batch_fn.contains(r#"${(flag) ? ' open=""' : ''}"#));
384
+ // Value-style attribute keeps its string value
385
+ assert!(compiled.batch_fn.contains(r#"href="${item.url}""#));
386
+ // DOM property keeps its value form
387
+ assert!(compiled.batch_fn.contains(r#"value="${item.v}""#));
388
+ }
389
+
390
+ #[test]
391
+ fn test_unsafe_template_skips_batch_fn() {
392
+ let html = r#"
393
+ <!-- each rows as row -->
394
+ <li>@[$.unsafe(row.markup)]</li>
395
+ <!-- /each -->
396
+ "#;
397
+
398
+ // RawHtml semantics can't ride a template literal — no batch function
399
+ assert!(build_iteration_optimizations(html).is_none());
400
+ }
401
+
326
402
  #[test]
327
403
  fn test_nested_iteration_compiled() {
328
404
  let html = r#"
@@ -395,19 +395,15 @@ impl ManifestBuilder {
395
395
  }
396
396
  }
397
397
 
398
- // Extract template HTML between start and end
398
+ // Extract template HTML between start and end — VERBATIM, including
399
+ // whitespace-only text nodes. The manifest's child keys are childNodes
400
+ // indices computed on the pre-stamp DOM; restoration re-inserts this
401
+ // template, and only an exact node-count round-trip keeps those
402
+ // indices valid for every sibling that follows the restored region.
399
403
  if let Some(end) = end_idx {
400
404
  let template_nodes: Vec<_> = siblings.iter()
401
405
  .skip(start_idx + 1)
402
406
  .take(end - start_idx - 1)
403
- .filter(|node| {
404
- // Skip whitespace-only text nodes
405
- if let NodeData::Text { contents } = &node.data {
406
- !contents.borrow().trim().is_empty()
407
- } else {
408
- true // Keep all non-text nodes
409
- }
410
- })
411
407
  .collect();
412
408
 
413
409
  return self.serialize_nodes(&template_nodes);
@@ -195,7 +195,7 @@ impl HtmlParser {
195
195
  let closing_pattern = format!(r"</{}>", regex::escape(tag_name));
196
196
 
197
197
  // Find all occurrences
198
- let mut matches: Vec<(usize, usize, String, HashMap<String, String>)> = Vec::new();
198
+ let mut matches: Vec<(usize, usize, String, Vec<(String, String)>)> = Vec::new();
199
199
 
200
200
  // Find opening tags
201
201
  for cap in tag_re.find_iter(&result) {
@@ -228,14 +228,9 @@ impl HtmlParser {
228
228
  // Replace from end to start to maintain indices
229
229
  matches.reverse();
230
230
  for (start, end, slot_content, props) in matches {
231
- let mut replacement = element.content.clone();
232
-
233
- // Replace props: for each prop like headline="@[pageTitle]",
234
- // replace @[headline] in content with @[pageTitle]
235
- for (prop_name, prop_value) in props {
236
- let prop_binding = format!("@[{}]", prop_name);
237
- replacement = replacement.replace(&prop_binding, &prop_value);
238
- }
231
+ // Substitute props with full runtime parity (bindings,
232
+ // expressions, directive comments, event handlers)
233
+ let mut replacement = substitute_props(&element.content, &props);
239
234
 
240
235
  // Replace <slot> tags — wrap children in <slot> boundary, or remove if empty
241
236
  let slot_wrapped = if slot_content.trim().is_empty() {
@@ -360,7 +355,10 @@ impl HtmlParser {
360
355
  let slot_content = result[open_end..close_start].to_string();
361
356
 
362
357
  let attrs_str = format!("{} {}", attrs_before, attrs_after);
363
- let props = Self::parse_props(&attrs_str);
358
+ let props: Vec<(String, String)> = Self::parse_props(&attrs_str)
359
+ .into_iter()
360
+ .filter(|(n, _)| n != "src" && n != "class")
361
+ .collect();
364
362
 
365
363
  Some((open_tag.start(), close_end, props, slot_content))
366
364
  }).collect();
@@ -370,12 +368,8 @@ impl HtmlParser {
370
368
  sorted.sort_by(|a, b| b.0.cmp(&a.0));
371
369
 
372
370
  for (start, end, props, slot_content) in &sorted {
373
- let mut replacement = component_content.to_string();
374
-
375
- for (prop_name, prop_value) in props {
376
- let prop_binding = format!("@[{}]", prop_name);
377
- replacement = replacement.replace(&prop_binding, prop_value);
378
- }
371
+ let mut replacement = substitute_props(component_content, props);
372
+ replacement = neuter_component_scripts(&replacement);
379
373
 
380
374
  let slot_wrapped = if slot_content.trim().is_empty() { String::new() } else { format!("<slot>{}</slot>", slot_content) };
381
375
  replacement = replacement.replace("<slot></slot>", &slot_wrapped);
@@ -425,7 +419,10 @@ impl HtmlParser {
425
419
  let slot_content = result[open_end..close_start].to_string();
426
420
 
427
421
  let attrs_str = format!("{} {}", attrs_before, attrs_after);
428
- let props = Self::parse_props(&attrs_str);
422
+ let props: Vec<(String, String)> = Self::parse_props(&attrs_str)
423
+ .into_iter()
424
+ .filter(|(n, _)| n != "src" && n != "class")
425
+ .collect();
429
426
 
430
427
  Some((open_tag.start(), close_end, src.to_string(), props, slot_content))
431
428
  }).collect();
@@ -447,11 +444,9 @@ impl HtmlParser {
447
444
  }
448
445
  };
449
446
 
450
- if let Some(mut replacement) = external_cache.get(&normalized_src).cloned() {
451
- for (prop_name, prop_value) in &props {
452
- let prop_binding = format!("@[{}]", prop_name);
453
- replacement = replacement.replace(&prop_binding, prop_value);
454
- }
447
+ if let Some(template) = external_cache.get(&normalized_src) {
448
+ let mut replacement = substitute_props(template, &props);
449
+ replacement = neuter_component_scripts(&replacement);
455
450
 
456
451
  let slot_wrapped = if slot_content.trim().is_empty() { String::new() } else { format!("<slot>{}</slot>", slot_content) };
457
452
  replacement = replacement.replace("<slot></slot>", &slot_wrapped);
@@ -473,24 +468,192 @@ impl HtmlParser {
473
468
  result
474
469
  }
475
470
 
476
- /// Parse component props from attributes string
477
- /// Example: ` headline="@[pageTitle]" theme="dark"` -> {"headline": "@[pageTitle]", "theme": "dark"}
478
- fn parse_props(attrs_str: &str) -> HashMap<String, String> {
479
- let mut props = HashMap::new();
471
+ /// Parse component props from attributes string, in source order.
472
+ /// Supports valued (`headline="@[pageTitle]"`) and bare boolean
473
+ /// (`dndDisabled`) attributes the runtime receives bare attributes from
474
+ /// the DOM with an empty-string value, so they're captured the same here.
475
+ fn parse_props(attrs_str: &str) -> Vec<(String, String)> {
476
+ let mut props = Vec::new();
480
477
 
481
- // Match attribute="value" pairs
482
- let attr_re = regex::Regex::new(r#"(\w+)="([^"]*)""#).unwrap();
478
+ let attr_re = regex::Regex::new(r#"([\w-]+)(?:="([^"]*)")?"#).unwrap();
483
479
 
484
480
  for cap in attr_re.captures_iter(attrs_str) {
485
- if let (Some(name), Some(value)) = (cap.get(1), cap.get(2)) {
486
- props.insert(name.as_str().to_string(), value.as_str().to_string());
487
- }
481
+ let name = cap.get(1).unwrap().as_str().to_string();
482
+ let value = cap.get(2).map(|m| m.as_str().to_string()).unwrap_or_default();
483
+ props.push((name, value));
488
484
  }
489
485
 
490
486
  props
491
487
  }
492
488
  }
493
489
 
490
+ /// Neuter component scripts inlined from `<component src>` templates:
491
+ /// `<script type="module">` becomes `<script type="vibe-module">` so the
492
+ /// browser does NOT execute it as a native page module (wrong timing —
493
+ /// pre-boot, placeholder `$`, no live state merge). The runtime executes
494
+ /// vibe-module scripts through the same injected-component() path it uses
495
+ /// for fetched component scripts, giving compiled and runtime pages one
496
+ /// script-execution pipeline with identical semantics. Runs before slot
497
+ /// inlining so caller-authored slot scripts stay native.
498
+ fn neuter_component_scripts(html: &str) -> String {
499
+ html.replace("<script type=\"module\"", "<script type=\"vibe-module\"")
500
+ }
501
+
502
+ fn is_ident_byte(b: u8) -> bool {
503
+ b.is_ascii_alphanumeric() || b == b'_' || b == b'$'
504
+ }
505
+
506
+ /// Case-insensitive free-identifier substitution, mirroring the runtime's
507
+ /// renderPropsAndSlot idRegex: the name must not be preceded by an identifier
508
+ /// character or `.` (property access) and not followed by an identifier
509
+ /// character. Case-insensitive because HTML lowercases attribute names while
510
+ /// component templates reference the author's camelCase identifiers.
511
+ fn substitute_identifier(expr: &str, name: &str, replacement: &str) -> String {
512
+ let bytes = expr.as_bytes();
513
+ let nb = name.as_bytes();
514
+ let nlen = nb.len();
515
+ if nlen == 0 {
516
+ return expr.to_string();
517
+ }
518
+ let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
519
+ let mut i = 0;
520
+ while i < bytes.len() {
521
+ if i + nlen <= bytes.len()
522
+ && bytes[i..i + nlen].eq_ignore_ascii_case(nb)
523
+ && (i == 0 || (!is_ident_byte(bytes[i - 1]) && bytes[i - 1] != b'.'))
524
+ && (i + nlen == bytes.len() || !is_ident_byte(bytes[i + nlen]))
525
+ {
526
+ out.extend_from_slice(replacement.as_bytes());
527
+ i += nlen;
528
+ } else {
529
+ out.push(bytes[i]);
530
+ i += 1;
531
+ }
532
+ }
533
+ String::from_utf8(out).unwrap()
534
+ }
535
+
536
+ /// `$.propName` rewrites inside event-handler bodies (the runtime's stateRegex
537
+ /// pass): for a binding prop `value="@[email]"`, `$.value = this.value`
538
+ /// becomes `$.email = this.value` — DOM property reads (`this.value`) stay.
539
+ fn substitute_state_ref(body: &str, name: &str, replacement: &str) -> String {
540
+ let bytes = body.as_bytes();
541
+ let nb = name.as_bytes();
542
+ let nlen = nb.len();
543
+ if nlen == 0 {
544
+ return body.to_string();
545
+ }
546
+ let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
547
+ let mut i = 0;
548
+ while i < bytes.len() {
549
+ if bytes[i] == b'$'
550
+ && i + 2 + nlen <= bytes.len()
551
+ && bytes[i + 1] == b'.'
552
+ && bytes[i + 2..i + 2 + nlen].eq_ignore_ascii_case(nb)
553
+ && (i + 2 + nlen == bytes.len() || !is_ident_byte(bytes[i + 2 + nlen]))
554
+ {
555
+ out.extend_from_slice(b"$.");
556
+ out.extend_from_slice(replacement.as_bytes());
557
+ i += 2 + nlen;
558
+ } else {
559
+ out.push(bytes[i]);
560
+ i += 1;
561
+ }
562
+ }
563
+ String::from_utf8(out).unwrap()
564
+ }
565
+
566
+ /// Substitute component props into a template — the compiled twin of the
567
+ /// runtime's renderPropsAndSlot (runtime/component.js). Both sides must
568
+ /// transform identically:
569
+ /// - exact `@[propName]` bindings (case-insensitive)
570
+ /// - prop identifiers inside other `@[expr]` bindings
571
+ /// - prop identifiers inside directive comments (`if` / `else if` / `each`)
572
+ /// - `$.propName` references inside event-handler attribute bodies
573
+ /// Binding props (`prop="@[path]"`) rewrite identifiers to the bound path;
574
+ /// literal props inject the value (bare boolean attrs → true, strings
575
+ /// JSON-quoted, numerics raw).
576
+ fn substitute_props(template: &str, props: &[(String, String)]) -> String {
577
+ let binding_re = regex::Regex::new(r"@\[([^\]]+)\]").unwrap();
578
+ let directive_re = regex::Regex::new(r"(?s)<!--\s*(if|else if|each)\s+(.*?)\s*-->").unwrap();
579
+ let event_re = regex::Regex::new(r#"\bon(\w+)="([^"]*)""#).unwrap();
580
+ let numeric_re = regex::Regex::new(r"(?i)^-?\d+(\.\d+)?(e[+-]?\d+)?$").unwrap();
581
+
582
+ let mut html = template.to_string();
583
+
584
+ for (prop_name, prop_value) in props {
585
+ let exact_re =
586
+ regex::Regex::new(&format!(r"(?i)@\[{}\]", regex::escape(prop_name))).unwrap();
587
+
588
+ let binding_path = prop_value
589
+ .strip_prefix("@[")
590
+ .and_then(|v| v.strip_suffix(']'));
591
+
592
+ let (exact_repl, ident_repl, each_repl, state_repl) = if let Some(path) = binding_path {
593
+ (
594
+ format!("@[{}]", path),
595
+ format!("({})", path),
596
+ path.to_string(),
597
+ path.to_string(),
598
+ )
599
+ } else {
600
+ let literal = if prop_value.is_empty() {
601
+ "true".to_string()
602
+ } else if numeric_re.is_match(prop_value) {
603
+ prop_value.clone()
604
+ } else {
605
+ serde_json::to_string(prop_value).unwrap()
606
+ };
607
+ (prop_value.clone(), literal.clone(), literal.clone(), literal)
608
+ };
609
+
610
+ html = exact_re
611
+ .replace_all(&html, regex::NoExpand(exact_repl.as_str()))
612
+ .to_string();
613
+
614
+ html = binding_re
615
+ .replace_all(&html, |c: &regex::Captures| {
616
+ let expr = c.get(1).unwrap().as_str();
617
+ let rewritten = substitute_identifier(expr, prop_name, &ident_repl);
618
+ if rewritten == expr {
619
+ c.get(0).unwrap().as_str().to_string()
620
+ } else {
621
+ format!("@[{}]", rewritten)
622
+ }
623
+ })
624
+ .to_string();
625
+
626
+ html = directive_re
627
+ .replace_all(&html, |c: &regex::Captures| {
628
+ let kw = c.get(1).unwrap().as_str();
629
+ let expr = c.get(2).unwrap().as_str();
630
+ let repl = if kw == "each" { each_repl.as_str() } else { ident_repl.as_str() };
631
+ let rewritten = substitute_identifier(expr, prop_name, repl);
632
+ if rewritten == expr {
633
+ c.get(0).unwrap().as_str().to_string()
634
+ } else {
635
+ format!("<!-- {} {} -->", kw, rewritten)
636
+ }
637
+ })
638
+ .to_string();
639
+
640
+ html = event_re
641
+ .replace_all(&html, |c: &regex::Captures| {
642
+ let ev = c.get(1).unwrap().as_str();
643
+ let body = c.get(2).unwrap().as_str();
644
+ let rewritten = substitute_state_ref(body, prop_name, &state_repl);
645
+ if rewritten == body {
646
+ c.get(0).unwrap().as_str().to_string()
647
+ } else {
648
+ format!(r#"on{}="{}""#, ev, rewritten)
649
+ }
650
+ })
651
+ .to_string();
652
+ }
653
+
654
+ html
655
+ }
656
+
494
657
  /// Transform custom HTML elements to divs with classes
495
658
  fn transform_custom_tags_to_divs(content: &str, reserved_elements: &[String]) -> String {
496
659
  let mut result = content.to_string();
package/llms.txt CHANGED
@@ -289,7 +289,7 @@ window.$ = vibe(
289
289
 
290
290
  **Parameters:**
291
291
  - `initialState` — object containing initial state values
292
- - `config` — optional object. Currently supports `{ debug: boolean }`. A third positional argument can pass a target selector (defaults to `body`).
292
+ - `config` — optional object. Supports `{ debug: boolean, noCache: boolean }` (`noCache` disables the component template cache — components are otherwise fetched once per `src` and reused across instances/navigation). A third positional argument can pass a target selector (defaults to `body`).
293
293
 
294
294
  **Returns:** Reactive proxy. Assign it to `window.$` so inline event handlers and bindings can find it.
295
295