@ape-egg/vibe 2.1.9 → 2.1.10

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.10] - 2026-06-21
4
+
5
+ ### Fixed
6
+
7
+ - **Compiler 1.9.5 → 1.9.6 — watch mode now picks up files created mid-session** (`compiler/src/compiler/watcher.rs`) — the dependency graph was built once at startup and never relearned a changed component's own dependencies. On a component edit the watcher only looked up *existing* dependents and bailed when there were none; unlike the page branch, it never re-extracted the component's `<component src>` references. So a component file created after the watcher started — and any new `<component src>` reference added by editing an existing component (e.g. a `Layout` that adds a freshly-created child) — was never recorded in the graph: editing that new file resolved to zero dependent pages and was a silent no-op (its content only reached the output incidentally, the next time some dependent page recompiled for another reason). The dep-refresh is now a single `DependencyGraph::refresh_file` shared by **both** the page and component branches (previously only pages refreshed, inline), so a component edit relearns its outgoing edges. A forward map (`component_to_used_components`) mirrors `component_to_components` so a component's own edges can be cleared symmetrically when they change. Because referencing a new component always means saving a referrer, that save now teaches the graph the new edge, and subsequent edits to the new file recompile every page that inlines it — no dev-server restart needed. Tests: `refreshing_a_component_learns_newly_added_child_references`, `refreshing_a_file_drops_stale_dependencies`, `refreshing_a_component_drops_stale_child_references` (`watcher.rs`).
8
+ - **Compiler 1.9.5 → 1.9.6 — inlining a component whose end tag was split across lines left a stray `>`** (`compiler/src/parser/html.rs`) — `find_matching_close` already tolerated whitespace before the `>` of an end tag (`</component\n>`, as produced by Prettier's whitespace-controlled wrapping), but the inliner computed the replacement's end as `close_start + len("</component>")` — too short by exactly that whitespace. The trailing `>` was left behind as a stray text node beside the inlined component (the literal `>` that leaked next to components in the app). `find_matching_close` now returns the close tag's real `(start, end)` byte offsets and both inlining paths slice to `end`. Tests: `inline_component_with_split_end_tag_leaves_no_stray_gt`, and the extended `find_matching_close_tolerates_whitespace_in_end_tag` (`html.rs`).
9
+
3
10
  ## [2.1.9] - 2026-06-20
4
11
 
5
12
  ### Fixed
@@ -1599,7 +1599,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1599
1599
 
1600
1600
  [[package]]
1601
1601
  name = "vibe-compiler"
1602
- version = "1.9.5"
1602
+ version = "1.9.6"
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.5"
3
+ version = "1.9.6"
4
4
  edition = "2021"
5
5
  description = "Vibe framework compiler - compiles Vibe source files into optimized output"
6
6
  authors = ["Kim Korte"]
@@ -17,6 +17,10 @@ pub struct DependencyGraph {
17
17
  component_to_components: HashMap<PathBuf, HashSet<PathBuf>>,
18
18
  /// page path -> set of component paths it uses
19
19
  page_to_components: HashMap<PathBuf, HashSet<PathBuf>>,
20
+ /// component path -> set of component paths it uses (forward edges, so a
21
+ /// component's own deps can be cleared on refresh — the mirror of
22
+ /// component_to_components)
23
+ component_to_used_components: HashMap<PathBuf, HashSet<PathBuf>>,
20
24
  }
21
25
 
22
26
  impl DependencyGraph {
@@ -25,6 +29,7 @@ impl DependencyGraph {
25
29
  component_to_pages: HashMap::new(),
26
30
  component_to_components: HashMap::new(),
27
31
  page_to_components: HashMap::new(),
32
+ component_to_used_components: HashMap::new(),
28
33
  }
29
34
  }
30
35
 
@@ -35,7 +40,12 @@ impl DependencyGraph {
35
40
  self.component_to_components
36
41
  .entry(component.clone())
37
42
  .or_insert_with(HashSet::new)
38
- .insert(file);
43
+ .insert(file.clone());
44
+
45
+ self.component_to_used_components
46
+ .entry(file)
47
+ .or_insert_with(HashSet::new)
48
+ .insert(component);
39
49
  } else {
40
50
  // Page uses component
41
51
  self.component_to_pages
@@ -50,6 +60,39 @@ impl DependencyGraph {
50
60
  }
51
61
  }
52
62
 
63
+ /// Replace `file`'s outgoing dependency edges with `deps`. Works for both
64
+ /// pages and components, so a newly-added `<component src>` reference (or a
65
+ /// removed one) is learned the moment the referrer is saved. This is what lets
66
+ /// the watcher pick up files created mid-session: referencing a new component
67
+ /// always means editing a referrer, and that edit refreshes the graph here.
68
+ pub fn refresh_file(&mut self, file: &Path, deps: HashSet<PathBuf>, file_is_component: bool) {
69
+ // Clear the file's old outgoing edges (and their reverse entries) so a
70
+ // dependency it no longer uses stops mapping back to it.
71
+ let forward = if file_is_component {
72
+ &mut self.component_to_used_components
73
+ } else {
74
+ &mut self.page_to_components
75
+ };
76
+
77
+ if let Some(old_deps) = forward.remove(file) {
78
+ let reverse = if file_is_component {
79
+ &mut self.component_to_components
80
+ } else {
81
+ &mut self.component_to_pages
82
+ };
83
+ for dep in old_deps {
84
+ if let Some(users) = reverse.get_mut(&dep) {
85
+ users.remove(file);
86
+ }
87
+ }
88
+ }
89
+
90
+ // Add the current edges.
91
+ for dep in deps {
92
+ self.add_dependency(file.to_path_buf(), dep, file_is_component);
93
+ }
94
+ }
95
+
53
96
  /// Get all pages that transitively depend on a component (includes component -> component -> page chains)
54
97
  pub fn get_all_dependent_pages(&self, component: &Path) -> HashSet<PathBuf> {
55
98
  let mut all_pages = HashSet::new();
@@ -442,6 +485,26 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
442
485
  // Component changed - recompile all transitively dependent pages
443
486
  // Canonicalize to match how dependencies were stored (handles case sensitivity)
444
487
  let path_canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
488
+
489
+ // Refresh this component's own dependency edges so a
490
+ // newly-added <component src> (e.g. a child file created
491
+ // mid-session) is learned. Without this the new child maps
492
+ // to zero dependent pages and editing it is a silent no-op.
493
+ if path.exists() {
494
+ if let Ok(html) = std::fs::read_to_string(path) {
495
+ let components_path = config.source.join(&config.components);
496
+ let deps: HashSet<PathBuf> =
497
+ extract_component_dependencies(&html, &components_path)
498
+ .into_iter()
499
+ .map(|dep| {
500
+ let dep_absolute = config.source.join(&dep);
501
+ dep_absolute.canonicalize().unwrap_or(dep_absolute)
502
+ })
503
+ .collect();
504
+ graph.refresh_file(path, deps, true);
505
+ }
506
+ }
507
+
445
508
  let dependent_pages = graph.get_all_dependent_pages(&path_canonical);
446
509
  if !dependent_pages.is_empty() {
447
510
  println!("{} {} changed", "[watch]".cyan(), relative_path.display());
@@ -503,27 +566,19 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
503
566
  println!("{} {} changed", "[watch]".cyan(), relative_path.display());
504
567
  pages_to_recompile.insert(path.clone());
505
568
 
506
- // Rebuild dependencies for this page
569
+ // Rebuild dependencies for this page (shared with the
570
+ // component path via refresh_file).
507
571
  if let Ok(html) = std::fs::read_to_string(path) {
508
572
  let components_path = config.source.join(&config.components);
509
- let deps = extract_component_dependencies(&html, &components_path);
510
-
511
- // Clear old dependencies for this page
512
- if let Some(old_deps) = graph.page_to_components.get(path) {
513
- for dep in old_deps {
514
- if let Some(pages) = graph.component_to_pages.get_mut(dep) {
515
- pages.remove(path);
516
- }
517
- }
518
- }
519
-
520
- // Add new dependencies
521
- graph.page_to_components.insert(path.clone(), HashSet::new());
522
- for dep in deps {
523
- let dep_absolute = config.source.join(&dep);
524
- let dep_canonical = dep_absolute.canonicalize().unwrap_or(dep_absolute);
525
- graph.add_dependency(path.clone(), dep_canonical, false);
526
- }
573
+ let deps: HashSet<PathBuf> =
574
+ extract_component_dependencies(&html, &components_path)
575
+ .into_iter()
576
+ .map(|dep| {
577
+ let dep_absolute = config.source.join(&dep);
578
+ dep_absolute.canonicalize().unwrap_or(dep_absolute)
579
+ })
580
+ .collect();
581
+ graph.refresh_file(path, deps, false);
527
582
  }
528
583
  }
529
584
  }
@@ -768,6 +823,95 @@ mod tests {
768
823
  assert!(stale_components.iter().all(|c| c.starts_with("/src/components")));
769
824
  }
770
825
 
826
+ // The new-file bug: a component created mid-session is referenced by editing
827
+ // an existing component (e.g. Layout adds <component src="SeasonDowntime">).
828
+ // The watcher must refresh the edited component's own deps so that edge is
829
+ // learned — otherwise editing the new component maps to zero pages and is a
830
+ // silent no-op.
831
+ #[test]
832
+ fn refreshing_a_component_learns_newly_added_child_references() {
833
+ let layout = PathBuf::from("/src/components/Layout.html");
834
+ let page = PathBuf::from("/src/pages/index.html");
835
+ let downtime = PathBuf::from("/src/components/SeasonDowntime.html");
836
+
837
+ let mut graph = DependencyGraph::new();
838
+ graph.add_dependency(page.clone(), layout.clone(), false); // page uses layout
839
+
840
+ // Brand-new component nobody references yet.
841
+ assert!(
842
+ graph.get_all_dependent_pages(&downtime).is_empty(),
843
+ "nothing uses the new component yet"
844
+ );
845
+
846
+ // Layout is edited to add <component src="SeasonDowntime">. The watcher
847
+ // refreshes layout's deps, which must learn the layout -> downtime edge.
848
+ let mut new_deps = HashSet::new();
849
+ new_deps.insert(downtime.clone());
850
+ graph.refresh_file(&layout, new_deps, true);
851
+
852
+ // Now editing the new component must map back to the page that inlines it.
853
+ assert!(
854
+ graph.get_all_dependent_pages(&downtime).contains(&page),
855
+ "after layout learns the new component, editing it must recompile the page"
856
+ );
857
+ }
858
+
859
+ // refresh_file replaces a file's edges, so a dependency it no longer uses is
860
+ // dropped (no phantom recompiles of files that reference the removed dep).
861
+ #[test]
862
+ fn refreshing_a_file_drops_stale_dependencies() {
863
+ let page = PathBuf::from("/src/pages/index.html");
864
+ let old = PathBuf::from("/src/components/Old.html");
865
+ let new = PathBuf::from("/src/components/New.html");
866
+
867
+ let mut graph = DependencyGraph::new();
868
+ graph.add_dependency(page.clone(), old.clone(), false);
869
+ assert!(graph.get_all_dependent_pages(&old).contains(&page));
870
+
871
+ // Page edited: now uses `new` instead of `old`.
872
+ let mut deps = HashSet::new();
873
+ deps.insert(new.clone());
874
+ graph.refresh_file(&page, deps, false);
875
+
876
+ assert!(
877
+ graph.get_all_dependent_pages(&new).contains(&page),
878
+ "new dependency is learned"
879
+ );
880
+ assert!(
881
+ !graph.get_all_dependent_pages(&old).contains(&page),
882
+ "stale dependency is dropped"
883
+ );
884
+ }
885
+
886
+ // Same as above but for a component's own deps (component -> component edges),
887
+ // which previously had no forward tracking to clear.
888
+ #[test]
889
+ fn refreshing_a_component_drops_stale_child_references() {
890
+ let page = PathBuf::from("/src/pages/index.html");
891
+ let parent = PathBuf::from("/src/components/Parent.html");
892
+ let old_child = PathBuf::from("/src/components/OldChild.html");
893
+ let new_child = PathBuf::from("/src/components/NewChild.html");
894
+
895
+ let mut graph = DependencyGraph::new();
896
+ graph.add_dependency(page.clone(), parent.clone(), false); // page uses parent
897
+ graph.add_dependency(parent.clone(), old_child.clone(), true); // parent uses old_child
898
+ assert!(graph.get_all_dependent_pages(&old_child).contains(&page));
899
+
900
+ // Parent edited: swaps old_child for new_child.
901
+ let mut deps = HashSet::new();
902
+ deps.insert(new_child.clone());
903
+ graph.refresh_file(&parent, deps, true);
904
+
905
+ assert!(
906
+ graph.get_all_dependent_pages(&new_child).contains(&page),
907
+ "new child reference is learned transitively"
908
+ );
909
+ assert!(
910
+ !graph.get_all_dependent_pages(&old_child).contains(&page),
911
+ "stale child reference is dropped"
912
+ );
913
+ }
914
+
771
915
  #[test]
772
916
  fn cache_key_is_source_relative_with_leading_slash() {
773
917
  let source = PathBuf::from("/proj/src");
@@ -258,7 +258,11 @@ impl HtmlParser {
258
258
  /// Used during recursive component fetching to resolve nested components
259
259
  /// Find the start position of the matching close tag, handling nesting of the same tag.
260
260
  /// `after_open`: byte position immediately after the `>` of the opening tag.
261
- fn find_matching_close(content: &str, after_open: usize, tag_name: &str) -> Option<usize> {
261
+ /// Returns `(start, end)` byte offsets of the matching close tag: `start` is
262
+ /// the `<` of `</tag…>` (the slot-content boundary), `end` is just past its
263
+ /// `>`. Because the end tag may carry whitespace before `>` (`</tag\n>`), the
264
+ /// caller must use this `end` rather than assuming a `</tag>`-length tag.
265
+ fn find_matching_close(content: &str, after_open: usize, tag_name: &str) -> Option<(usize, usize)> {
262
266
  // (?:\s|>) ensures <component> matches but not <component-foo> (hyphen is not \s or >)
263
267
  let open_re = regex::Regex::new(&format!(r"<{}(?:\s|>|/>)", regex::escape(tag_name))).unwrap();
264
268
  // `\s*>` tolerates whitespace before the `>` of an end tag — HTML allows
@@ -279,7 +283,7 @@ impl HtmlParser {
279
283
  (None, None) => return None,
280
284
  (None, Some((cs, ce))) => {
281
285
  depth -= 1;
282
- if depth == 0 { return Some(cursor + cs); }
286
+ if depth == 0 { return Some((cursor + cs, cursor + ce)); }
283
287
  cursor += ce;
284
288
  }
285
289
  (Some((os, oe)), None) => {
@@ -309,7 +313,7 @@ impl HtmlParser {
309
313
  }
310
314
  } else {
311
315
  depth -= 1;
312
- if depth == 0 { return Some(cursor + cs); }
316
+ if depth == 0 { return Some((cursor + cs, cursor + ce)); }
313
317
  cursor += ce;
314
318
  }
315
319
  }
@@ -354,8 +358,7 @@ impl HtmlParser {
354
358
  }
355
359
 
356
360
  let open_end = open_tag.end();
357
- let close_start = Self::find_matching_close(&result, open_end, tag_name)?;
358
- let close_end = close_start + format!("</{}>", tag_name).len();
361
+ let (close_start, close_end) = Self::find_matching_close(&result, open_end, tag_name)?;
359
362
  let slot_content = result[open_end..close_start].to_string();
360
363
 
361
364
  let attrs_str = format!("{} {}", attrs_before, attrs_after);
@@ -424,8 +427,7 @@ impl HtmlParser {
424
427
  }
425
428
 
426
429
  let open_end = open_tag.end();
427
- let close_start = Self::find_matching_close(&result, open_end, tag_name)?;
428
- let close_end = close_start + format!("</{}>", tag_name).len();
430
+ let (close_start, close_end) = Self::find_matching_close(&result, open_end, tag_name)?;
429
431
  let slot_content = result[open_end..close_start].to_string();
430
432
 
431
433
  let attrs_str = format!("{} {}", attrs_before, attrs_after);
@@ -810,13 +812,37 @@ mod tests {
810
812
  let content = "<component><a><component src=\"x\"></component\n></a></component><sibling></sibling>";
811
813
  let after_open = "<component>".len();
812
814
 
813
- let close = HtmlParser::find_matching_close(content, after_open, "component")
815
+ let (close, close_end) = HtmlParser::find_matching_close(content, after_open, "component")
814
816
  .expect("outer </component> must be found");
815
817
 
816
818
  // The match must be the OUTER close (immediately before <sibling>), not
817
819
  // an overshoot past it.
818
820
  assert!(content[close..].starts_with("</component>"), "matched wrong close: {:?}", &content[close..close + 14]);
819
821
  assert!(content[close..].contains("<sibling>"), "sibling was swallowed into the component");
822
+ // The reported end lands just past the close tag's `>`.
823
+ assert!(content[..close_end].ends_with('>'), "close_end must sit right after the `>`");
824
+ }
825
+
826
+ #[test]
827
+ fn inline_component_with_split_end_tag_leaves_no_stray_gt() {
828
+ // Prettier's whitespace-controlled wrapping splits an end tag across
829
+ // lines (`</component\n>`). find_matching_close tolerates that whitespace
830
+ // when locating the close, but the caller computed the replacement end as
831
+ // `close_start + len("</component>")` — too short by the whitespace before
832
+ // the `>`. The final `>` was therefore left behind as a stray text node
833
+ // (the literal ">" that leaked next to inlined components in the app).
834
+ let parser = HtmlParser::new(std::path::PathBuf::from("."));
835
+ let page = "<wrap><component src=\"/components/Potion.html\"></component\n></wrap>";
836
+ let template = "<icon potion></icon>";
837
+ let mut cache = HashMap::new();
838
+ cache.insert("/components/Potion.html".to_string(), template.to_string());
839
+
840
+ let out = parser.inline_component_elements(page, &cache);
841
+
842
+ assert_eq!(
843
+ out, "<wrap><component><icon potion></icon></component></wrap>",
844
+ "split end tag left a stray `>` (or otherwise mis-sliced the close): {out}"
845
+ );
820
846
  }
821
847
 
822
848
  #[test]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "2.1.9",
3
+ "version": "2.1.10",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",