@ape-egg/vibe 2.1.8 → 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 +14 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +1 -1
- package/compiler/src/Cargo.toml +1 -1
- package/compiler/src/compiler/compile.rs +12 -10
- package/compiler/src/compiler/component_tagger.rs +78 -28
- package/compiler/src/compiler/watcher.rs +164 -20
- package/compiler/src/parser/html.rs +125 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
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
|
+
|
|
10
|
+
## [2.1.9] - 2026-06-20
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **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`.
|
|
15
|
+
- **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`.
|
|
16
|
+
|
|
3
17
|
## [2.1.8] - 2026-06-19
|
|
4
18
|
|
|
5
19
|
### Changed
|
|
Binary file
|
|
Binary file
|
package/compiler/src/Cargo.lock
CHANGED
package/compiler/src/Cargo.toml
CHANGED
|
@@ -1270,17 +1270,19 @@ impl Compiler {
|
|
|
1270
1270
|
continue;
|
|
1271
1271
|
}
|
|
1272
1272
|
|
|
1273
|
-
//
|
|
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
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
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
|
|
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 (`>` -> `>`,
|
|
151
|
-
// `&&` -> `&&`) 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
|
|
@@ -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 =
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -367,6 +370,12 @@ impl HtmlParser {
|
|
|
367
370
|
Some((open_tag.start(), close_end, props, slot_content))
|
|
368
371
|
}).collect();
|
|
369
372
|
|
|
373
|
+
// Iter-prop components stay as runtime `<component src>` tags (see
|
|
374
|
+
// is_iter_prop_root). The content is the same for every match, so skip all.
|
|
375
|
+
if is_iter_prop_root(component_content) {
|
|
376
|
+
return result;
|
|
377
|
+
}
|
|
378
|
+
|
|
370
379
|
// Replace from end to start (sorted descending by start position)
|
|
371
380
|
let mut sorted = matches;
|
|
372
381
|
sorted.sort_by(|a, b| b.0.cmp(&a.0));
|
|
@@ -418,8 +427,7 @@ impl HtmlParser {
|
|
|
418
427
|
}
|
|
419
428
|
|
|
420
429
|
let open_end = open_tag.end();
|
|
421
|
-
let close_start = Self::find_matching_close(&result, open_end, tag_name)?;
|
|
422
|
-
let close_end = close_start + format!("</{}>", tag_name).len();
|
|
430
|
+
let (close_start, close_end) = Self::find_matching_close(&result, open_end, tag_name)?;
|
|
423
431
|
let slot_content = result[open_end..close_start].to_string();
|
|
424
432
|
|
|
425
433
|
let attrs_str = format!("{} {}", attrs_before, attrs_after);
|
|
@@ -449,6 +457,12 @@ impl HtmlParser {
|
|
|
449
457
|
};
|
|
450
458
|
|
|
451
459
|
if let Some(template) = external_cache.get(&normalized_src) {
|
|
460
|
+
// Iter-prop components stay as runtime `<component src>` tags so the
|
|
461
|
+
// runtime renders them via its __vibeiterprops path — inlining
|
|
462
|
+
// breaks the enclosing loop's scope (see is_iter_prop_root).
|
|
463
|
+
if is_iter_prop_root(template) {
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
452
466
|
let mut replacement = substitute_props(template, &props);
|
|
453
467
|
replacement = neuter_component_scripts(&replacement);
|
|
454
468
|
|
|
@@ -578,6 +592,30 @@ fn substitute_state_ref(body: &str, name: &str, replacement: &str) -> String {
|
|
|
578
592
|
/// runtime's renderPropsAndSlot (runtime/component.js). Both sides must
|
|
579
593
|
/// transform identically:
|
|
580
594
|
/// - exact `@[propName]` bindings (case-insensitive)
|
|
595
|
+
/// Whether a component template's ROOT is an array-literal each
|
|
596
|
+
/// (`<!-- each [expr] as a -->`). Such a component receives its iterable as a prop
|
|
597
|
+
/// and depends on the runtime's `__vibeiterprops` indirection: the runtime
|
|
598
|
+
/// evaluates the prop binding in the *enclosing* loop scope, stores the value in a
|
|
599
|
+
/// global registry slot, and rewrites the each to iterate that slot. Inlining
|
|
600
|
+
/// instead bakes the call-site's parent-loop alias straight into the each
|
|
601
|
+
/// (`[card.signatureAbility]`); the runtime evaluates an array-literal iterable in
|
|
602
|
+
/// GLOBAL scope (that's the registry-slot pattern), where the alias is undefined →
|
|
603
|
+
/// the loop yields zero items (the empty AbilityCell / status-chip bug in compiled
|
|
604
|
+
/// mode). So such components are NOT inlined — they stay as runtime
|
|
605
|
+
/// `<component src>` tags, and the compiler ships their source so the runtime can
|
|
606
|
+
/// fetch and instantiate them exactly as it does in non-compiled mode.
|
|
607
|
+
fn is_iter_prop_root(template: &str) -> bool {
|
|
608
|
+
let t = template.trim_start();
|
|
609
|
+
t.strip_prefix("<!--")
|
|
610
|
+
.map(str::trim_start)
|
|
611
|
+
.and_then(|r| r.strip_prefix("each"))
|
|
612
|
+
.map(str::trim_start)
|
|
613
|
+
.is_some_and(|after| after.starts_with('['))
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/// Substitute component props into a template.
|
|
617
|
+
///
|
|
618
|
+
/// Rewrites:
|
|
581
619
|
/// - prop identifiers inside other `@[expr]` bindings
|
|
582
620
|
/// - prop identifiers inside directive comments (`if` / `else if` / `each`)
|
|
583
621
|
/// - `$.propName` references inside event-handler attribute bodies
|
|
@@ -774,13 +812,37 @@ mod tests {
|
|
|
774
812
|
let content = "<component><a><component src=\"x\"></component\n></a></component><sibling></sibling>";
|
|
775
813
|
let after_open = "<component>".len();
|
|
776
814
|
|
|
777
|
-
let close = HtmlParser::find_matching_close(content, after_open, "component")
|
|
815
|
+
let (close, close_end) = HtmlParser::find_matching_close(content, after_open, "component")
|
|
778
816
|
.expect("outer </component> must be found");
|
|
779
817
|
|
|
780
818
|
// The match must be the OUTER close (immediately before <sibling>), not
|
|
781
819
|
// an overshoot past it.
|
|
782
820
|
assert!(content[close..].starts_with("</component>"), "matched wrong close: {:?}", &content[close..close + 14]);
|
|
783
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
|
+
);
|
|
784
846
|
}
|
|
785
847
|
|
|
786
848
|
#[test]
|
|
@@ -853,4 +915,59 @@ mod tests {
|
|
|
853
915
|
// A literal prop after the `>=` prop still substitutes → tag parsed fully.
|
|
854
916
|
assert!(out.contains(r#"n="7""#), "count not substituted: {out}");
|
|
855
917
|
}
|
|
918
|
+
|
|
919
|
+
#[test]
|
|
920
|
+
fn each_root_component_is_left_for_runtime() {
|
|
921
|
+
// A component whose ROOT is an array-literal each (`<!-- each [prop] as a -->`)
|
|
922
|
+
// depends on the runtime's __vibeiterprops indirection (the runtime evaluates
|
|
923
|
+
// the prop in the enclosing loop scope and stashes it in a global registry
|
|
924
|
+
// slot the each iterates). Inlining bakes the parent-loop alias into the each
|
|
925
|
+
// (`[card.signatureAbility]`); the runtime evaluates array-literal iterables
|
|
926
|
+
// in GLOBAL scope, where that alias is undefined → zero items (the empty
|
|
927
|
+
// AbilityCell / status-chip bug). So such a component must NOT be inlined:
|
|
928
|
+
// leave the `<component src=...>` tag for the runtime to fetch and instantiate.
|
|
929
|
+
let parser = HtmlParser::new(std::path::PathBuf::from("."));
|
|
930
|
+
let page = concat!(
|
|
931
|
+
r#"<!-- each cards as card --><card-section>"#,
|
|
932
|
+
r#"<component src="/components/AbilityCell.html" ability="@[card.signatureAbility]"></component>"#,
|
|
933
|
+
r#"</card-section><!-- /each -->"#,
|
|
934
|
+
);
|
|
935
|
+
// AbilityCell's root is an each over the `[ability]` array-literal prop.
|
|
936
|
+
let template = r#"<!-- each [ability] as a --><ability-tile><icon @[a.icon]></icon></ability-tile><!-- /each -->"#;
|
|
937
|
+
let mut cache = HashMap::new();
|
|
938
|
+
cache.insert("/components/AbilityCell.html".to_string(), template.to_string());
|
|
939
|
+
|
|
940
|
+
let out = parser.inline_component_elements(page, &cache);
|
|
941
|
+
|
|
942
|
+
// Left un-inlined for the runtime, with its prop binding intact…
|
|
943
|
+
assert!(
|
|
944
|
+
out.contains(r#"<component src="/components/AbilityCell.html""#),
|
|
945
|
+
"each-root component must be left un-inlined for the runtime: {out}"
|
|
946
|
+
);
|
|
947
|
+
assert!(
|
|
948
|
+
out.contains(r#"ability="@[card.signatureAbility]""#),
|
|
949
|
+
"prop binding lost on un-inlined component: {out}"
|
|
950
|
+
);
|
|
951
|
+
// …and the parent-loop alias must NOT have been baked into an each iterable.
|
|
952
|
+
assert!(
|
|
953
|
+
!out.contains("each [card.signatureAbility]"),
|
|
954
|
+
"parent-loop alias was inlined into the component each (the bug): {out}"
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
#[test]
|
|
959
|
+
fn ordinary_component_still_inlines() {
|
|
960
|
+
// Guard: a normal component (root is NOT an array-literal each) must still
|
|
961
|
+
// inline as before — the skip is scoped to the iter-prop pattern only.
|
|
962
|
+
let parser = HtmlParser::new(std::path::PathBuf::from("."));
|
|
963
|
+
let page = r#"<page><component src="/components/Badge.html" label="@[title]"></component></page>"#;
|
|
964
|
+
let template = r#"<badge-pill>@[label]</badge-pill>"#;
|
|
965
|
+
let mut cache = HashMap::new();
|
|
966
|
+
cache.insert("/components/Badge.html".to_string(), template.to_string());
|
|
967
|
+
|
|
968
|
+
let out = parser.inline_component_elements(page, &cache);
|
|
969
|
+
|
|
970
|
+
assert!(out.contains("<badge-pill>"), "ordinary component should inline: {out}");
|
|
971
|
+
assert!(!out.contains(r#"src="/components/Badge.html""#), "ordinary component src should be gone: {out}");
|
|
972
|
+
}
|
|
856
973
|
}
|