@ape-egg/vibe 2.3.0 → 3.0.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.
Files changed (57) hide show
  1. package/README.md +14 -4
  2. package/boot.js +4 -4
  3. package/component.js +27 -29
  4. package/hot-module-refresh.js +4 -4
  5. package/index.js +10 -15
  6. package/llms.txt +8 -6
  7. package/package.json +19 -14
  8. package/runtime/affected.js +159 -36
  9. package/runtime/cleanup.js +45 -1
  10. package/runtime/component.js +312 -99
  11. package/runtime/conditionals.js +111 -14
  12. package/runtime/debug.js +24 -0
  13. package/runtime/dispatch.js +172 -0
  14. package/runtime/hydrate.js +251 -111
  15. package/runtime/index.js +180 -71
  16. package/runtime/iterate.js +125 -50
  17. package/runtime/iteration-utils.js +59 -8
  18. package/runtime/manifest.js +77 -2
  19. package/runtime/parse.js +69 -5
  20. package/runtime/pre-compiled-iterations.js +19 -6
  21. package/runtime/pre-compiled-manifest.js +13 -4
  22. package/runtime/staging.js +153 -0
  23. package/runtime/state.js +31 -0
  24. package/runtime/tracking.js +173 -0
  25. package/runtime/utils.js +155 -78
  26. package/spa.js +77 -14
  27. package/vibe.css +8 -4
  28. package/CHANGELOG.md +0 -1196
  29. package/ROADMAP.md +0 -397
  30. package/compiler/bin/vibe-compile.js +0 -121
  31. package/compiler/native/.gitkeep +0 -0
  32. package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
  33. package/compiler/native/vibe-compiler-linux-x64 +0 -0
  34. package/compiler/src/Cargo.lock +0 -2023
  35. package/compiler/src/Cargo.toml +0 -38
  36. package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +0 -241
  37. package/compiler/src/compiler/binding_case.rs +0 -88
  38. package/compiler/src/compiler/compile.rs +0 -2880
  39. package/compiler/src/compiler/component_tagger.rs +0 -469
  40. package/compiler/src/compiler/iteration_optimizer.rs +0 -455
  41. package/compiler/src/compiler/js_analyzer.rs +0 -715
  42. package/compiler/src/compiler/manifest_builder.rs +0 -693
  43. package/compiler/src/compiler/mod.rs +0 -16
  44. package/compiler/src/compiler/name_binding_protect.rs +0 -207
  45. package/compiler/src/compiler/reassignment_analyzer.rs +0 -456
  46. package/compiler/src/compiler/spa.rs +0 -477
  47. package/compiler/src/compiler/state_extractor.rs +0 -263
  48. package/compiler/src/compiler/value_stamper.rs +0 -921
  49. package/compiler/src/compiler/watcher.rs +0 -1278
  50. package/compiler/src/config.rs +0 -279
  51. package/compiler/src/main.rs +0 -358
  52. package/compiler/src/parser/element.rs +0 -96
  53. package/compiler/src/parser/html.rs +0 -1004
  54. package/compiler/src/parser/mod.rs +0 -8
  55. package/runtime/pre-compiled-manifest.test.mjs +0 -58
  56. package/runtime/scope.js +0 -50
  57. package/test-results/.last-run.json +0 -4
@@ -1,455 +0,0 @@
1
- use regex::Regex;
2
- use serde::{Serialize, Deserialize};
3
- use std::collections::HashMap;
4
-
5
- #[derive(Debug, Clone, Serialize, Deserialize)]
6
- #[serde(rename_all = "camelCase")]
7
- pub struct CompiledIteration {
8
- pub item_alias: String,
9
- pub index_alias: String,
10
- pub state_path: String,
11
- pub batch_fn: String,
12
- }
13
-
14
- #[derive(Debug, Clone, Serialize, Deserialize)]
15
- pub struct IterationOptimizations {
16
- pub iterations: HashMap<String, CompiledIteration>,
17
- }
18
-
19
- /// Generate compiled batch functions for iterations in HTML
20
- pub fn build_iteration_optimizations(html: &str) -> Option<IterationOptimizations> {
21
- let iterations = extract_and_compile_iterations(html);
22
-
23
- if iterations.is_empty() {
24
- None
25
- } else {
26
- Some(IterationOptimizations { iterations })
27
- }
28
- }
29
-
30
- /// Extract <!-- each --> blocks and compile them to batch functions
31
- fn extract_and_compile_iterations(html: &str) -> HashMap<String, CompiledIteration> {
32
- let mut result = HashMap::new();
33
-
34
- // Match: <!-- each arrayPath as item, index -->
35
- let each_re = Regex::new(r"<!--\s*each\s+([^\s]+)\s+as\s+([^\s,]+)(?:\s*,\s*([^\s]+))?\s*-->").unwrap();
36
-
37
- let mut search_start = 0;
38
-
39
- while let Some(start_match) = each_re.find_at(html, search_start) {
40
- let captures = each_re.captures(&html[start_match.start()..]).unwrap();
41
- let state_path = captures.get(1).unwrap().as_str();
42
- let item_alias = captures.get(2).unwrap().as_str();
43
- let index_alias = captures.get(3).map(|m| m.as_str()).unwrap_or("index");
44
-
45
- let template_start = start_match.end();
46
-
47
- // Find matching <!-- /each --> using depth counting
48
- if let Some((end_pos, end_after)) = find_matching_each_end(html, template_start) {
49
- let template_html = &html[template_start..end_pos];
50
-
51
- // Skip if this iteration is inside a conditional (parent won't be compiled)
52
- if is_in_conditional_context(html, start_match.start()) {
53
- search_start = end_after;
54
- continue;
55
- }
56
-
57
- // Check if template has nested structures (skip if so)
58
- if has_nested_structures(template_html) {
59
- search_start = end_after;
60
- continue;
61
- }
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
-
73
- // Generate hash for this template
74
- let hash = generate_template_hash(template_html);
75
-
76
- // Compile template to batch function
77
- let batch_fn = compile_template_to_batch_fn(
78
- template_html,
79
- item_alias,
80
- index_alias,
81
- );
82
-
83
- result.insert(hash, CompiledIteration {
84
- item_alias: item_alias.to_string(),
85
- index_alias: index_alias.to_string(),
86
- state_path: state_path.to_string(),
87
- batch_fn,
88
- });
89
-
90
- search_start = end_after;
91
- } else {
92
- break;
93
- }
94
- }
95
-
96
- result
97
- }
98
-
99
- /// Check if template contains conditionals (we skip these)
100
- /// Nested iterations are supported, but not if parent has conditionals
101
- fn has_nested_structures(template: &str) -> bool {
102
- template.contains("<!-- if")
103
- }
104
-
105
- /// Check if we're inside a non-compiled parent iteration
106
- /// This happens when the template contains conditionals that prevent compilation
107
- fn is_in_conditional_context(html: &str, pos: usize) -> bool {
108
- // Look backwards from current position for unclosed conditionals
109
- let before = &html[..pos];
110
- let if_count = before.matches("<!-- if").count();
111
- let endif_count = before.matches("<!-- /if").count();
112
- if_count > endif_count
113
- }
114
-
115
- /// Generate a stable hash for a template
116
- fn generate_template_hash(template: &str) -> String {
117
- use std::collections::hash_map::DefaultHasher;
118
- use std::hash::{Hash, Hasher};
119
-
120
- let mut hasher = DefaultHasher::new();
121
- template.trim().hash(&mut hasher);
122
- let hash = hasher.finish();
123
- format!("iter_{:x}", hash)
124
- }
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
- /// Turn ` data-vibe-namebind="@[A]@[B]"` back into ` @[A]="" @[B]=""` so each relocated
161
- /// binding is rendered as a name-binding (` resolvedName=""`) rather than having its
162
- /// resolved value written into the transport attribute. See name_binding_protect.
163
- fn restore_relocated_name_bindings(template: &str) -> String {
164
- let wrapper = Regex::new(
165
- r#"\s+data-vibe-namebind="((?:@\[(?:[^\[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+\])+)""#,
166
- )
167
- .unwrap();
168
- let one = Regex::new(r#"@\[(?:[^\[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+\]"#).unwrap();
169
- wrapper
170
- .replace_all(template, |caps: &regex::Captures| {
171
- let mut s = String::new();
172
- for m in one.find_iter(&caps[1]) {
173
- s.push(' ');
174
- s.push_str(m.as_str());
175
- s.push_str("=\"\"");
176
- }
177
- s
178
- })
179
- .to_string()
180
- }
181
-
182
- fn is_value_style_attr(name: &str) -> bool {
183
- VALUE_ATTRS.contains(&name)
184
- || name.starts_with("data-")
185
- || name.starts_with("aria-")
186
- || name.starts_with("on")
187
- }
188
-
189
- /// Escape a static text chunk for a JS template-literal context.
190
- fn escape_tpl_text(text: &str) -> String {
191
- text.replace('\\', "\\\\")
192
- .replace('`', "\\`")
193
- .replace("${", "\\${")
194
- }
195
-
196
- /// Compile template HTML to a batch function string
197
- /// Supports nested <!-- each --> blocks
198
- fn compile_template_to_batch_fn(
199
- template: &str,
200
- item_alias: &str,
201
- index_alias: &str,
202
- ) -> String {
203
- // Normalize static boolean attributes: <div attr=""> → <div attr>
204
- // (matches CSS attribute selectors). Runs on raw template text, before
205
- // emission — emitted JS below also contains attr="" inside string
206
- // literals, which must not be touched.
207
- let boolean_attr_re = Regex::new(r#"(\w+)="""#).unwrap();
208
- let cleaned = boolean_attr_re.replace_all(template, "$1");
209
-
210
- let body = emit_template_literal(&cleaned);
211
-
212
- format!(
213
- r#"(arr, $) => {{ let html = ''; const len = arr.length; for (let {index} = 0; {index} < len; {index}++) {{ const {item} = arr[{index}]; html += `{body}`; }} return html; }}"#,
214
- index = index_alias,
215
- item = item_alias,
216
- body = body
217
- )
218
- }
219
-
220
- /// Emit the body of a JS template literal for a template chunk:
221
- /// - static text escaped for template-literal context
222
- /// - attribute pure bindings classified like hydrate.js / compileBatchFn:
223
- /// DOM properties and value-style attributes keep `attr="${expr}"`,
224
- /// boolean-coerced attributes become `${(expr) ? ' attr=""' : ''}` so the
225
- /// attribute is ABSENT when falsy and present-empty when truthy
226
- /// - remaining @[expr] bindings (text content, partial attribute values)
227
- /// become `${expr}` interpolations
228
- /// - nested <!-- each --> blocks become inline IIFE loops, recursively
229
- ///
230
- /// Anything the runtime clone+hydrate path renders, this output has to render
231
- /// identically — it is the compiled twin of compileBatchFn in runtime/iterate.js.
232
- fn emit_template_literal(template: &str) -> String {
233
- // Restore name-bindings the compiler relocated into `data-vibe-namebind` (their
234
- // expression has whitespace, so it could not be an HTML attribute name) back to the
235
- // `@[expr]=""` form the binding pass below renders as ` resolvedName=""`. The compiled
236
- // twin of the same step in runtime/iterate.js compileBatchFn.
237
- let template = &restore_relocated_name_bindings(template);
238
-
239
- let each_re = Regex::new(r"<!--\s*each\s+([^\s]+)\s+as\s+([^\s,]+)(?:\s*,\s*([^\s]+))?\s*-->").unwrap();
240
- let end_re = Regex::new(r"<!--\s*/each\s*-->").unwrap();
241
- let attr_binding_re = Regex::new(r#"(\s)([\w-]+)="@\[([^\]]+)\]""#).unwrap();
242
- let binding_re = Regex::new(r"@\[([^\]]+)\]").unwrap();
243
-
244
- let mut out = String::new();
245
- let mut pos = 0;
246
-
247
- while pos < template.len() {
248
- let next_each = each_re.find_at(template, pos);
249
- let next_attr = attr_binding_re.find_at(template, pos);
250
- let next_binding = binding_re.find_at(template, pos);
251
-
252
- // Earliest match wins; attr-binding outranks plain binding at the same
253
- // region (the plain regex would match inside the attr form).
254
- let candidates = [
255
- next_each.map(|m| (m.start(), 0u8)),
256
- next_attr.map(|m| (m.start(), 1u8)),
257
- next_binding.map(|m| (m.start(), 2u8)),
258
- ];
259
- let Some(&(start, kind)) = candidates
260
- .iter()
261
- .flatten()
262
- .min_by_key(|(s, k)| (*s, *k))
263
- else {
264
- out.push_str(&escape_tpl_text(&template[pos..]));
265
- break;
266
- };
267
-
268
- out.push_str(&escape_tpl_text(&template[pos..start]));
269
-
270
- match kind {
271
- 0 => {
272
- // Nested <!-- each --> → inline IIFE loop
273
- let m = next_each.unwrap();
274
- let caps = each_re.captures(&template[m.start()..]).unwrap();
275
- let arr = caps.get(1).unwrap().as_str();
276
- let item = caps.get(2).unwrap().as_str();
277
- let idx = caps.get(3).map(|c| c.as_str()).unwrap_or("index");
278
-
279
- if let Some((end_pos, _)) = find_matching_each_end(template, m.end()) {
280
- let inner = emit_template_literal(&template[m.end()..end_pos]);
281
- out.push_str(&format!(
282
- "${{(() => {{ let inner = ''; const len_{idx} = {arr}.length; for (let {idx} = 0; {idx} < len_{idx}; {idx}++) {{ const {item} = {arr}[{idx}]; inner += `{inner_body}`; }} return inner; }})()}}",
283
- idx = idx,
284
- arr = arr,
285
- item = item,
286
- inner_body = inner
287
- ));
288
- let end_match = end_re.find_at(template, end_pos).unwrap();
289
- pos = end_match.end();
290
- } else {
291
- // Unbalanced each — emit as text and move on
292
- out.push_str(&escape_tpl_text(&template[m.start()..m.end()]));
293
- pos = m.end();
294
- }
295
- }
296
- 1 => {
297
- // Attribute pure binding: attr="@[expr]"
298
- let m = next_attr.unwrap();
299
- let caps = attr_binding_re.captures(&template[m.start()..]).unwrap();
300
- let ws = caps.get(1).unwrap().as_str();
301
- let name = caps.get(2).unwrap().as_str();
302
- let expr = caps.get(3).unwrap().as_str();
303
- let name_lc = name.to_lowercase();
304
-
305
- if DOM_PROPERTIES.contains(&name_lc.as_str()) || is_value_style_attr(&name_lc) {
306
- out.push_str(&format!("{ws}{name}=\"${{{expr}}}\""));
307
- } else {
308
- out.push_str(&format!("${{({expr}) ? ' {name}=\"\"' : ''}}"));
309
- }
310
- pos = m.end();
311
- }
312
- _ => {
313
- // Plain binding: text content or partial attribute value
314
- let m = next_binding.unwrap();
315
- let caps = binding_re.captures(&template[m.start()..]).unwrap();
316
- out.push_str(&format!("${{{}}}", caps.get(1).unwrap().as_str()));
317
- pos = m.end();
318
- }
319
- }
320
- }
321
-
322
- out
323
- }
324
-
325
- /// Find matching <!-- /each --> comment using depth counting
326
- /// Returns (position_before_comment, position_after_comment)
327
- fn find_matching_each_end(html: &str, start_pos: usize) -> Option<(usize, usize)> {
328
- let each_re = Regex::new(r"<!--\s*each\s+").unwrap();
329
- let end_re = Regex::new(r"<!--\s*/each\s*-->").unwrap();
330
-
331
- let mut depth = 1;
332
- let mut search_pos = start_pos;
333
-
334
- while depth > 0 {
335
- let next_start = each_re.find_at(html, search_pos);
336
- let next_end = end_re.find_at(html, search_pos);
337
-
338
- match (next_start, next_end) {
339
- (Some(start_match), Some(end_match)) if start_match.start() < end_match.start() => {
340
- // Found nested <!-- each --> before <!-- /each -->
341
- depth += 1;
342
- search_pos = start_match.end();
343
- }
344
- (_, Some(end_match)) => {
345
- // Found <!-- /each -->
346
- depth -= 1;
347
- if depth == 0 {
348
- return Some((end_match.start(), end_match.end()));
349
- }
350
- search_pos = end_match.end();
351
- }
352
- _ => return None, // No matching end found
353
- }
354
- }
355
-
356
- None
357
- }
358
-
359
- #[cfg(test)]
360
- mod tests {
361
- use super::*;
362
-
363
- #[test]
364
- fn test_simple_iteration() {
365
- let html = r#"
366
- <!-- each items as item -->
367
- <li>@[item.name]</li>
368
- <!-- /each -->
369
- "#;
370
-
371
- let opts = build_iteration_optimizations(html);
372
- assert!(opts.is_some());
373
-
374
- let iterations = &opts.unwrap().iterations;
375
- assert_eq!(iterations.len(), 1);
376
-
377
- let (_, compiled) = iterations.iter().next().unwrap();
378
- assert_eq!(compiled.item_alias, "item");
379
- assert_eq!(compiled.index_alias, "index");
380
- assert_eq!(compiled.state_path, "items");
381
- assert!(compiled.batch_fn.contains("html += `"));
382
- }
383
-
384
- #[test]
385
- fn test_iteration_with_index() {
386
- let html = r#"
387
- <!-- each items as item, idx -->
388
- <li>[@[idx]] @[item.name]</li>
389
- <!-- /each -->
390
- "#;
391
-
392
- let opts = build_iteration_optimizations(html);
393
- let iterations = &opts.unwrap().iterations;
394
- let (_, compiled) = iterations.iter().next().unwrap();
395
- assert_eq!(compiled.index_alias, "idx");
396
- }
397
-
398
- #[test]
399
- fn test_boolean_attr_binding_emits_conditional_presence() {
400
- let html = r#"
401
- <!-- each items as item -->
402
- <attr-target open="@[flag]" href="@[item.url]" value="@[item.v]"></attr-target>
403
- <!-- /each -->
404
- "#;
405
-
406
- let opts = build_iteration_optimizations(html);
407
- let iterations = &opts.unwrap().iterations;
408
- let (_, compiled) = iterations.iter().next().unwrap();
409
-
410
- // Boolean-coerced attribute: absent when falsy, present-empty when truthy
411
- assert!(compiled.batch_fn.contains(r#"${(flag) ? ' open=""' : ''}"#));
412
- // Value-style attribute keeps its string value
413
- assert!(compiled.batch_fn.contains(r#"href="${item.url}""#));
414
- // DOM property keeps its value form
415
- assert!(compiled.batch_fn.contains(r#"value="${item.v}""#));
416
- }
417
-
418
- #[test]
419
- fn test_unsafe_template_skips_batch_fn() {
420
- let html = r#"
421
- <!-- each rows as row -->
422
- <li>@[$.unsafe(row.markup)]</li>
423
- <!-- /each -->
424
- "#;
425
-
426
- // RawHtml semantics can't ride a template literal — no batch function
427
- assert!(build_iteration_optimizations(html).is_none());
428
- }
429
-
430
- #[test]
431
- fn test_nested_iteration_compiled() {
432
- let html = r#"
433
- <!-- each categories as cat -->
434
- <div>
435
- <!-- each cat.items as item -->
436
- <span>@[item]</span>
437
- <!-- /each -->
438
- </div>
439
- <!-- /each -->
440
- "#;
441
-
442
- let opts = build_iteration_optimizations(html);
443
- // Should compile nested iterations
444
- assert!(opts.is_some());
445
-
446
- let iterations = &opts.unwrap().iterations;
447
- assert_eq!(iterations.len(), 1);
448
-
449
- let (_, compiled) = iterations.iter().next().unwrap();
450
- // Should contain nested loop code with template literals
451
- assert!(compiled.batch_fn.contains("cat.items"));
452
- assert!(compiled.batch_fn.contains("let inner = ''"));
453
- assert!(compiled.batch_fn.contains("inner += `"));
454
- }
455
- }