@ape-egg/vibe 2.1.8 → 2.1.9

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.1.9] - 2026-06-20
4
+
5
+ ### Fixed
6
+
7
+ - **Compiler 1.9.4 → 1.9.5 — array-literal each-root components broke when inlined** (`compiler/src/parser/html.rs`, `compile.rs`) — a component whose root is `<!-- each [prop] as a -->` receives its iterable as a prop and relies on the runtime's `__vibeiterprops` indirection: the runtime evaluates the prop binding in the *enclosing* loop scope, stashes the value in a global registry slot, and iterates that slot. Inlining instead baked the call-site's parent-loop alias straight into the each (`[card.signatureAbility]`); the runtime evaluates an array-literal iterable in global scope, where that alias is undefined, so the loop yielded zero items (the empty `AbilityCell` / status-chip bug in compiled mode). Such components (`is_iter_prop_root`) are now left as runtime `<component src>` tags instead of being inlined — in both the `src=` and custom-element inlining paths — and the `components/` directory is always mirrored to the output so the runtime can fetch their source, exactly as in non-compiled mode. Tests: `each_root_component_is_left_for_runtime`, `ordinary_component_still_inlines` (`html.rs`), and `tests/compiler/components`.
8
+ - **Compiler 1.9.4 → 1.9.5 — a stateful component nested inside another stole the inner's `this.` bindings** (`compiler/src/compiler/component_tagger.rs`) — the outer component's build-time `this.` → component-id rewrite descended through a nested component that registers its OWN `component({...})` state, claiming the inner's bindings and `if`/`each` directives with the outer id before the inner was reached. The inner's live state (registered under its own runtime id) then never reached its markup → empty each-loops and dead bindings (the `DebugContent` → `ScalingModal` empty-legend bug). The rewrite now stops at any nested state-registering component (`is_state_registering_component`); each component owns the `this.` inside it and gets its own id + rewrite when `walk_tag_and_extract` reaches it. Test: `nested_component_this_resolves_to_own_id`.
9
+
3
10
  ## [2.1.8] - 2026-06-19
4
11
 
5
12
  ### Changed
@@ -1599,7 +1599,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1599
1599
 
1600
1600
  [[package]]
1601
1601
  name = "vibe-compiler"
1602
- version = "1.9.4"
1602
+ version = "1.9.5"
1603
1603
  dependencies = [
1604
1604
  "clap",
1605
1605
  "colored",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vibe-compiler"
3
- version = "1.9.4"
3
+ version = "1.9.5"
4
4
  edition = "2021"
5
5
  description = "Vibe framework compiler - compiles Vibe source files into optimized output"
6
6
  authors = ["Kim Korte"]
@@ -1270,17 +1270,19 @@ impl Compiler {
1270
1270
  continue;
1271
1271
  }
1272
1272
 
1273
- // Handle components directory based on components_as_is flag
1273
+ // The components directory is always mirrored to the output. With
1274
+ // components_as_is every component is loaded at runtime; otherwise
1275
+ // most are inlined at build time, but iter-prop each-root components
1276
+ // are deliberately left as runtime `<component src>` tags (see
1277
+ // is_iter_prop_root in parser/html.rs) and the runtime fetches their
1278
+ // source from here — exactly as it does in non-compiled mode.
1274
1279
  if file_name == self.config.components {
1275
- if self.config.components_as_is {
1276
- // Copy components directory to output for runtime
1277
- let new_relative = if relative_path.is_empty() {
1278
- file_name.to_string()
1279
- } else {
1280
- format!("{}/{}", relative_path, file_name)
1281
- };
1282
- self.copy_directory(&path, &new_relative, canonical_source, stats)?;
1283
- }
1280
+ let new_relative = if relative_path.is_empty() {
1281
+ file_name.to_string()
1282
+ } else {
1283
+ format!("{}/{}", relative_path, file_name)
1284
+ };
1285
+ self.copy_directory(&path, &new_relative, canonical_source, stats)?;
1284
1286
  // Skip further processing (don't recurse into components)
1285
1287
  continue;
1286
1288
  }
@@ -142,33 +142,7 @@ impl ComponentTagger {
142
142
  // treated as a stateful component with the merged state of all its descendants.
143
143
  // That made rewrite_this_to_component_id rewrite every @[this.xxx] in the page
144
144
  // to @[_c0.xxx] before child components could claim their own bindings.
145
- let mut direct_scripts_html = String::new();
146
- for child in node.children.borrow().iter() {
147
- if let NodeData::Element { name: ref child_name, .. } = child.data {
148
- if child_name.local.as_ref() == "script" {
149
- // Read the script's raw text directly. Serializing the
150
- // element would HTML-escape JS operators (`>` -> `&gt;`,
151
- // `&&` -> `&amp;&amp;`) because the serializer loses the
152
- // rawtext context when it starts at the script's children;
153
- // the escaped source then fails to parse, dropping a
154
- // `component(stateVar)` component to the regex fallback
155
- // (which only matches `component({` literals) and leaving
156
- // it untagged.
157
- let mut script_text = String::new();
158
- for grandchild in child.children.borrow().iter() {
159
- if let NodeData::Text { ref contents } = grandchild.data {
160
- script_text.push_str(&contents.borrow());
161
- }
162
- }
163
- // Wrap in <script> so extract_from_html routes it through
164
- // the JS AST analyzer, which resolves `component(stateVar)`
165
- // via its declaration (the regex fallback can't).
166
- direct_scripts_html.push_str("<script>");
167
- direct_scripts_html.push_str(&script_text);
168
- direct_scripts_html.push_str("</script>");
169
- }
170
- }
171
- }
145
+ let direct_scripts_html = Self::direct_scripts_html(node);
172
146
 
173
147
  // A component must be tagged whenever it REGISTERS local state via a
174
148
  // `component(...)` call — that is what makes `this.X` in its markup
@@ -237,6 +211,53 @@ impl ComponentTagger {
237
211
  })
238
212
  }
239
213
 
214
+ /// Concatenate a node's DIRECT `<script>` children as `<script>…</script>`.
215
+ /// Reads raw text (not serialized) so JS operators (`>`, `&&`) aren't
216
+ /// HTML-escaped — the escaped source would fail the AST parse and drop a
217
+ /// `component(stateVar)` call to the regex fallback. Used both to extract a
218
+ /// wrapper's own state and to detect nested component boundaries.
219
+ fn direct_scripts_html(node: &Handle) -> String {
220
+ let mut out = String::new();
221
+ for child in node.children.borrow().iter() {
222
+ if let NodeData::Element { name: ref child_name, .. } = child.data {
223
+ if child_name.local.as_ref() == "script" {
224
+ let mut script_text = String::new();
225
+ for grandchild in child.children.borrow().iter() {
226
+ if let NodeData::Text { ref contents } = grandchild.data {
227
+ script_text.push_str(&contents.borrow());
228
+ }
229
+ }
230
+ out.push_str("<script>");
231
+ out.push_str(&script_text);
232
+ out.push_str("</script>");
233
+ }
234
+ }
235
+ }
236
+ out
237
+ }
238
+
239
+ /// Is this node a component wrapper that registers its OWN local state? Such
240
+ /// a node gets its own `_cN` id and `this.`→id pass when `walk_tag_and_extract`
241
+ /// reaches it, so an ancestor's rewrite must stop here — descending would let
242
+ /// the ancestor claim the nested component's bindings with the wrong id (the
243
+ /// DebugContent→ScalingModal empty-legend bug).
244
+ fn is_state_registering_component(node: &Handle) -> bool {
245
+ if let NodeData::Element { name, attrs, .. } = &node.data {
246
+ let tag_name = name.local.as_ref();
247
+ let borrowed = attrs.borrow();
248
+ let is_component = tag_name == "component" && !Self::has_src_attr(&borrowed);
249
+ let is_div_component = tag_name == "div"
250
+ && Self::has_class_component(&borrowed)
251
+ && !Self::has_src_attr(&borrowed);
252
+ if !(is_component || is_div_component) {
253
+ return false;
254
+ }
255
+ drop(borrowed);
256
+ return component_call_regex().is_match(&Self::direct_scripts_html(node));
257
+ }
258
+ false
259
+ }
260
+
240
261
  /// Rewrite `this.X` to `componentId.X` throughout a component's subtree:
241
262
  /// inside `@[...]` bindings (text + attributes, nested paths and multi-ref
242
263
  /// expressions) and inside if/each/else-if directive comments. Resolving the
@@ -312,8 +333,14 @@ impl ComponentTagger {
312
333
  }
313
334
  }
314
335
 
315
- // Recurse into children
336
+ // Recurse into children — but STOP at a nested component that registers
337
+ // its own state. It owns the `this.` inside it and gets its own id +
338
+ // rewrite when walk_tag_and_extract reaches it; descending here would
339
+ // claim its bindings with this ancestor's id (the empty-legend bug).
316
340
  for child in node.children.borrow().iter() {
341
+ if Self::is_state_registering_component(child) {
342
+ continue;
343
+ }
317
344
  Self::rewrite_node_recursive(child, binding_regex, this_prop, state_this_prop, component_id);
318
345
  }
319
346
  }
@@ -394,6 +421,29 @@ mod tests {
394
421
  );
395
422
  }
396
423
 
424
+ #[test]
425
+ fn nested_component_this_resolves_to_own_id() {
426
+ // DebugContent → ScalingModal shape: a stateful component nested inside
427
+ // another stateful component, each owning its own `this.`. The outer's
428
+ // this.→id rewrite must STOP at the inner component boundary — otherwise
429
+ // it claims the inner's bindings/directives with the OUTER id before the
430
+ // inner is reached, and the inner's live state (registered under its own
431
+ // runtime id) never reaches the markup → empty each-loops, dead bindings.
432
+ let html = r#"<!DOCTYPE html><html><body><component><script>component({ outer: 1 })</script><outer-mark>@[this.outer]</outer-mark><component><script>component({ inner: 2, items: [] })</script><modal-root open="@[this.open]"><!-- each this.items as it --><inner-mark>@[this.inner]</inner-mark><!-- /each --></modal-root></component></component></body></html>"#;
433
+
434
+ let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
435
+
436
+ // Outer is _c0 (visited first), inner is _c1 (reached on recursion).
437
+ assert!(result.html.contains("@[_c0.outer]"), "outer binding wrong: {}", result.html);
438
+ assert!(result.html.contains("@[_c1.inner]"), "inner binding not rewritten to own id: {}", result.html);
439
+ assert!(result.html.contains("each _c1.items as it"), "inner each not rewritten to own id: {}", result.html);
440
+ assert!(result.html.contains("open=\"@[_c1.open]\""), "inner attr binding not rewritten to own id: {}", result.html);
441
+ // The outer must NOT have claimed any of the inner's bindings/directives.
442
+ assert!(!result.html.contains("@[_c0.inner]"), "outer claimed inner text binding: {}", result.html);
443
+ assert!(!result.html.contains("each _c0.items"), "outer claimed inner each: {}", result.html);
444
+ assert!(!result.html.contains("@[_c0.open]"), "outer claimed inner attr binding: {}", result.html);
445
+ }
446
+
397
447
  #[test]
398
448
  fn tag_multiple_components() {
399
449
  // Each wrapper registers component-local state, so all three are tagged
@@ -367,6 +367,12 @@ impl HtmlParser {
367
367
  Some((open_tag.start(), close_end, props, slot_content))
368
368
  }).collect();
369
369
 
370
+ // Iter-prop components stay as runtime `<component src>` tags (see
371
+ // is_iter_prop_root). The content is the same for every match, so skip all.
372
+ if is_iter_prop_root(component_content) {
373
+ return result;
374
+ }
375
+
370
376
  // Replace from end to start (sorted descending by start position)
371
377
  let mut sorted = matches;
372
378
  sorted.sort_by(|a, b| b.0.cmp(&a.0));
@@ -449,6 +455,12 @@ impl HtmlParser {
449
455
  };
450
456
 
451
457
  if let Some(template) = external_cache.get(&normalized_src) {
458
+ // Iter-prop components stay as runtime `<component src>` tags so the
459
+ // runtime renders them via its __vibeiterprops path — inlining
460
+ // breaks the enclosing loop's scope (see is_iter_prop_root).
461
+ if is_iter_prop_root(template) {
462
+ continue;
463
+ }
452
464
  let mut replacement = substitute_props(template, &props);
453
465
  replacement = neuter_component_scripts(&replacement);
454
466
 
@@ -578,6 +590,30 @@ fn substitute_state_ref(body: &str, name: &str, replacement: &str) -> String {
578
590
  /// runtime's renderPropsAndSlot (runtime/component.js). Both sides must
579
591
  /// transform identically:
580
592
  /// - exact `@[propName]` bindings (case-insensitive)
593
+ /// Whether a component template's ROOT is an array-literal each
594
+ /// (`<!-- each [expr] as a -->`). Such a component receives its iterable as a prop
595
+ /// and depends on the runtime's `__vibeiterprops` indirection: the runtime
596
+ /// evaluates the prop binding in the *enclosing* loop scope, stores the value in a
597
+ /// global registry slot, and rewrites the each to iterate that slot. Inlining
598
+ /// instead bakes the call-site's parent-loop alias straight into the each
599
+ /// (`[card.signatureAbility]`); the runtime evaluates an array-literal iterable in
600
+ /// GLOBAL scope (that's the registry-slot pattern), where the alias is undefined →
601
+ /// the loop yields zero items (the empty AbilityCell / status-chip bug in compiled
602
+ /// mode). So such components are NOT inlined — they stay as runtime
603
+ /// `<component src>` tags, and the compiler ships their source so the runtime can
604
+ /// fetch and instantiate them exactly as it does in non-compiled mode.
605
+ fn is_iter_prop_root(template: &str) -> bool {
606
+ let t = template.trim_start();
607
+ t.strip_prefix("<!--")
608
+ .map(str::trim_start)
609
+ .and_then(|r| r.strip_prefix("each"))
610
+ .map(str::trim_start)
611
+ .is_some_and(|after| after.starts_with('['))
612
+ }
613
+
614
+ /// Substitute component props into a template.
615
+ ///
616
+ /// Rewrites:
581
617
  /// - prop identifiers inside other `@[expr]` bindings
582
618
  /// - prop identifiers inside directive comments (`if` / `else if` / `each`)
583
619
  /// - `$.propName` references inside event-handler attribute bodies
@@ -853,4 +889,59 @@ mod tests {
853
889
  // A literal prop after the `>=` prop still substitutes → tag parsed fully.
854
890
  assert!(out.contains(r#"n="7""#), "count not substituted: {out}");
855
891
  }
892
+
893
+ #[test]
894
+ fn each_root_component_is_left_for_runtime() {
895
+ // A component whose ROOT is an array-literal each (`<!-- each [prop] as a -->`)
896
+ // depends on the runtime's __vibeiterprops indirection (the runtime evaluates
897
+ // the prop in the enclosing loop scope and stashes it in a global registry
898
+ // slot the each iterates). Inlining bakes the parent-loop alias into the each
899
+ // (`[card.signatureAbility]`); the runtime evaluates array-literal iterables
900
+ // in GLOBAL scope, where that alias is undefined → zero items (the empty
901
+ // AbilityCell / status-chip bug). So such a component must NOT be inlined:
902
+ // leave the `<component src=...>` tag for the runtime to fetch and instantiate.
903
+ let parser = HtmlParser::new(std::path::PathBuf::from("."));
904
+ let page = concat!(
905
+ r#"<!-- each cards as card --><card-section>"#,
906
+ r#"<component src="/components/AbilityCell.html" ability="@[card.signatureAbility]"></component>"#,
907
+ r#"</card-section><!-- /each -->"#,
908
+ );
909
+ // AbilityCell's root is an each over the `[ability]` array-literal prop.
910
+ let template = r#"<!-- each [ability] as a --><ability-tile><icon @[a.icon]></icon></ability-tile><!-- /each -->"#;
911
+ let mut cache = HashMap::new();
912
+ cache.insert("/components/AbilityCell.html".to_string(), template.to_string());
913
+
914
+ let out = parser.inline_component_elements(page, &cache);
915
+
916
+ // Left un-inlined for the runtime, with its prop binding intact…
917
+ assert!(
918
+ out.contains(r#"<component src="/components/AbilityCell.html""#),
919
+ "each-root component must be left un-inlined for the runtime: {out}"
920
+ );
921
+ assert!(
922
+ out.contains(r#"ability="@[card.signatureAbility]""#),
923
+ "prop binding lost on un-inlined component: {out}"
924
+ );
925
+ // …and the parent-loop alias must NOT have been baked into an each iterable.
926
+ assert!(
927
+ !out.contains("each [card.signatureAbility]"),
928
+ "parent-loop alias was inlined into the component each (the bug): {out}"
929
+ );
930
+ }
931
+
932
+ #[test]
933
+ fn ordinary_component_still_inlines() {
934
+ // Guard: a normal component (root is NOT an array-literal each) must still
935
+ // inline as before — the skip is scoped to the iter-prop pattern only.
936
+ let parser = HtmlParser::new(std::path::PathBuf::from("."));
937
+ let page = r#"<page><component src="/components/Badge.html" label="@[title]"></component></page>"#;
938
+ let template = r#"<badge-pill>@[label]</badge-pill>"#;
939
+ let mut cache = HashMap::new();
940
+ cache.insert("/components/Badge.html".to_string(), template.to_string());
941
+
942
+ let out = parser.inline_component_elements(page, &cache);
943
+
944
+ assert!(out.contains("<badge-pill>"), "ordinary component should inline: {out}");
945
+ assert!(!out.contains(r#"src="/components/Badge.html""#), "ordinary component src should be gone: {out}");
946
+ }
856
947
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "2.1.8",
3
+ "version": "2.1.9",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",