@ape-egg/vibe 2.1.9 → 2.1.11
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/binding_case.rs +88 -0
- package/compiler/src/compiler/component_tagger.rs +8 -0
- package/compiler/src/compiler/manifest_builder.rs +21 -1
- package/compiler/src/compiler/mod.rs +1 -0
- package/compiler/src/compiler/watcher.rs +164 -20
- package/compiler/src/parser/html.rs +34 -8
- package/package.json +1 -1
- package/runtime/utils.js +10 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.1.11] - 2026-06-22
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **Compiler 1.9.6 → 1.9.7 — name-binding expressions were lowercased, breaking camelCase identifiers** (`compiler/src/compiler/binding_case.rs` (new), `component_tagger.rs`, `manifest_builder.rs`) — html5ever lowercases attribute *names* per the HTML spec, and a name-binding lives in attribute-name position (`<icon @[selectedEquipProps(uuid).element]>`), so every parse/serialize round-trip lowered its expression (`selectedEquipProps` → `selectedequipprops`, undefined at runtime). Attribute *values* keep their case, so value-bindings were never affected. The new `binding_case` module snapshots the original-cased `@[...]` bindings before the round-trip (keyed by their lowercased form, first-wins on collision) and restores them afterward — applied both to the tagged HTML in `component_tagger` and to every string the manifest builder serializes, including its captured `name_bindings` array. Repro: `tests/compiler/name-binding-case`.
|
|
8
|
+
- **Name-binding paths wrapped in grouping parens by component-prop substitution failed to resolve** (`runtime/utils.js`) — `resolveCaseInsensitivePath` bailed on any `(`, but reusing a component rewrites a prop reference like `props.element` into `(equipmentDetailProps).element`, and the HTML parser lowercases the surrounding name-binding attribute. It now strips grouping parens to recover the plain dotted path and resolves it case-insensitively, still bailing on a genuine function call (`selectedEquipProps(uuid)`) that needs the full evaluator. Covered by `tests/unit/utils.test.js`.
|
|
9
|
+
|
|
10
|
+
## [2.1.10] - 2026-06-21
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **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`).
|
|
15
|
+
- **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`).
|
|
16
|
+
|
|
3
17
|
## [2.1.9] - 2026-06-20
|
|
4
18
|
|
|
5
19
|
### Fixed
|
|
Binary file
|
|
Binary file
|
package/compiler/src/Cargo.lock
CHANGED
package/compiler/src/Cargo.toml
CHANGED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
use regex::Regex;
|
|
2
|
+
use std::collections::HashMap;
|
|
3
|
+
use std::sync::OnceLock;
|
|
4
|
+
|
|
5
|
+
/// The canonical `@[...]` binding matcher, shared with manifest_builder /
|
|
6
|
+
/// value_stamper. A binding body is any run of non-bracket chars, optionally
|
|
7
|
+
/// containing a single bracketed group (`items[0]`).
|
|
8
|
+
fn binding_regex() -> &'static Regex {
|
|
9
|
+
static RE: OnceLock<Regex> = OnceLock::new();
|
|
10
|
+
RE.get_or_init(|| Regex::new(r"@\[((?:[^\[\]]|\[[^\]]*\])+)\]").unwrap())
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/// html5ever lowercases attribute NAMES per the HTML spec. A name-binding lives
|
|
14
|
+
/// in attribute-name position (`<icon @[selectedEquipProps(uuid).element]>`), so
|
|
15
|
+
/// its expression is lowercased by every parse/serialize round-trip — the
|
|
16
|
+
/// identifier `selectedEquipProps` becomes `selectedequipprops`, undefined at
|
|
17
|
+
/// runtime. Attribute VALUES keep their case, so value-bindings are unaffected.
|
|
18
|
+
///
|
|
19
|
+
/// We can't tell html5ever to preserve case, so we restore it ourselves: snapshot
|
|
20
|
+
/// the original-cased `@[...]` bindings from the input BEFORE the round-trip, then
|
|
21
|
+
/// map them back over the output AFTER. The map is keyed by the lowercased binding
|
|
22
|
+
/// (what the round-trip produces) → original binding.
|
|
23
|
+
///
|
|
24
|
+
/// First-wins on a lowercase collision (two differently-cased bindings that
|
|
25
|
+
/// lowercase to the same key): rare, and either casing is a defensible restore.
|
|
26
|
+
pub fn capture(input: &str) -> HashMap<String, String> {
|
|
27
|
+
let mut map = HashMap::new();
|
|
28
|
+
for caps in binding_regex().captures_iter(input) {
|
|
29
|
+
let original = caps.get(0).unwrap().as_str();
|
|
30
|
+
let lower = original.to_ascii_lowercase();
|
|
31
|
+
if lower != original {
|
|
32
|
+
map.entry(lower).or_insert_with(|| original.to_string());
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
map
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// Restore original casing of `@[...]` bindings in `output` using a map built by
|
|
39
|
+
/// [`capture`]. Only bindings whose lowercased form is a known key are rewritten;
|
|
40
|
+
/// everything else is left verbatim, so this is a no-op when nothing was lowered.
|
|
41
|
+
pub fn restore(output: &str, map: &HashMap<String, String>) -> String {
|
|
42
|
+
if map.is_empty() {
|
|
43
|
+
return output.to_string();
|
|
44
|
+
}
|
|
45
|
+
binding_regex()
|
|
46
|
+
.replace_all(output, |caps: ®ex::Captures| {
|
|
47
|
+
let matched = caps.get(0).unwrap().as_str();
|
|
48
|
+
match map.get(&matched.to_ascii_lowercase()) {
|
|
49
|
+
Some(original) => original.clone(),
|
|
50
|
+
None => matched.to_string(),
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
.to_string()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#[cfg(test)]
|
|
57
|
+
mod tests {
|
|
58
|
+
use super::*;
|
|
59
|
+
|
|
60
|
+
#[test]
|
|
61
|
+
fn restores_lowercased_name_binding() {
|
|
62
|
+
// Post-prop-substitution name binding (parens added by substitute_props),
|
|
63
|
+
// as it enters an html5ever round-trip. html5ever lowercases the whole
|
|
64
|
+
// attr name; restore puts the original casing back.
|
|
65
|
+
let input = "<icon @[(selectedEquipProps(uuid)).element]></icon>";
|
|
66
|
+
let map = capture(input);
|
|
67
|
+
let lowered = "<icon @[(selectedequipprops(uuid)).element]></icon>";
|
|
68
|
+
let restored = restore(lowered, &map);
|
|
69
|
+
assert_eq!(restored, input);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#[test]
|
|
73
|
+
fn leaves_value_bindings_untouched_when_already_correct() {
|
|
74
|
+
let input = r#"<card tinted="@[selectedEquipProps(uuid).element]"></card>"#;
|
|
75
|
+
let map = capture(input);
|
|
76
|
+
// Value bindings keep case through html5ever, so output already matches.
|
|
77
|
+
let restored = restore(input, &map);
|
|
78
|
+
assert_eq!(restored, input);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#[test]
|
|
82
|
+
fn noop_when_no_uppercase() {
|
|
83
|
+
let input = "<icon @[tooltip.props.element]></icon>";
|
|
84
|
+
let map = capture(input);
|
|
85
|
+
assert!(map.is_empty());
|
|
86
|
+
assert_eq!(restore(input, &map), input);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -42,6 +42,11 @@ impl ComponentTagger {
|
|
|
42
42
|
html_with_placeholders = html_with_placeholders.replace(content, &placeholder);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// Snapshot original-cased `@[...]` bindings before html5ever lowercases
|
|
46
|
+
// any that live in attribute-name position (name bindings); restored after
|
|
47
|
+
// serialization below.
|
|
48
|
+
let binding_cases = crate::compiler::binding_case::capture(&html_with_placeholders);
|
|
49
|
+
|
|
45
50
|
// Parse HTML (with placeholders instead of actual vibe-dehydrate content)
|
|
46
51
|
let dom = parse_document(RcDom::default(), Default::default())
|
|
47
52
|
.from_utf8()
|
|
@@ -64,6 +69,9 @@ impl ComponentTagger {
|
|
|
64
69
|
let mut modified_html = String::from_utf8(modified_html_bytes)
|
|
65
70
|
.map_err(|e| format!("Failed to convert HTML to UTF-8: {}", e))?;
|
|
66
71
|
|
|
72
|
+
// Restore original casing of name-binding expressions html5ever lowercased.
|
|
73
|
+
modified_html = crate::compiler::binding_case::restore(&modified_html, &binding_cases);
|
|
74
|
+
|
|
67
75
|
// Restore template content from placeholders
|
|
68
76
|
for (i, content) in template_placeholders.iter().enumerate() {
|
|
69
77
|
let placeholder = format!("<!--VIBE_TEMPLATE_PLACEHOLDER_{}-->", i);
|
|
@@ -4,12 +4,17 @@ use markup5ever_rcdom::{RcDom, NodeData, Handle};
|
|
|
4
4
|
use regex::Regex;
|
|
5
5
|
use serde::{Serialize, Serializer};
|
|
6
6
|
use serde_json::Value;
|
|
7
|
+
use std::cell::RefCell;
|
|
7
8
|
use std::collections::{HashMap, BTreeMap};
|
|
8
9
|
use crate::compiler::iteration_optimizer::build_iteration_optimizations;
|
|
9
10
|
|
|
10
11
|
pub struct ManifestBuilder {
|
|
11
12
|
binding_regex: Regex,
|
|
12
13
|
name_binding_fix_regex: Regex,
|
|
14
|
+
/// Original-cased `@[...]` bindings captured from the pre-parse HTML, keyed by
|
|
15
|
+
/// their lowercased form — used to undo html5ever's attribute-name lowercasing
|
|
16
|
+
/// of name bindings on every string this builder serializes back out.
|
|
17
|
+
binding_cases: RefCell<HashMap<String, String>>,
|
|
13
18
|
}
|
|
14
19
|
|
|
15
20
|
impl ManifestBuilder {
|
|
@@ -17,10 +22,16 @@ impl ManifestBuilder {
|
|
|
17
22
|
Self {
|
|
18
23
|
binding_regex: Regex::new(r"@\[((?:[^\[\]]|\[[^\]]*\])+)\]").unwrap(),
|
|
19
24
|
name_binding_fix_regex: Regex::new(r#"(@\[[^\]]+\])="+"#).unwrap(),
|
|
25
|
+
binding_cases: RefCell::new(HashMap::new()),
|
|
20
26
|
}
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
pub fn build_from_html(&self, html: &str, _state: &Value, iterations_as_is: bool) -> Result<ManifestNode, String> {
|
|
30
|
+
// Snapshot original-cased bindings before html5ever lowercases name
|
|
31
|
+
// bindings (attr-name position). Restored on serialized templates and on
|
|
32
|
+
// the captured name_bindings array.
|
|
33
|
+
*self.binding_cases.borrow_mut() = crate::compiler::binding_case::capture(html);
|
|
34
|
+
|
|
24
35
|
// Parse HTML
|
|
25
36
|
let dom = parse_document(RcDom::default(), Default::default())
|
|
26
37
|
.from_utf8()
|
|
@@ -133,7 +144,12 @@ impl ManifestBuilder {
|
|
|
133
144
|
while name_bindings.len() <= idx {
|
|
134
145
|
name_bindings.push(None);
|
|
135
146
|
}
|
|
136
|
-
|
|
147
|
+
// html5ever lowercased the attr name; restore original casing.
|
|
148
|
+
let restored = crate::compiler::binding_case::restore(
|
|
149
|
+
&attr_name,
|
|
150
|
+
&self.binding_cases.borrow(),
|
|
151
|
+
);
|
|
152
|
+
name_bindings[idx] = Some(restored);
|
|
137
153
|
}
|
|
138
154
|
// Check for attribute value bindings
|
|
139
155
|
else if self.binding_regex.is_match(&attr_value) {
|
|
@@ -441,6 +457,10 @@ impl ManifestBuilder {
|
|
|
441
457
|
// which is invalid HTML that browsers reject
|
|
442
458
|
html = self.name_binding_fix_regex.replace_all(&html, "$1").to_string();
|
|
443
459
|
|
|
460
|
+
// Restore original casing of any name-binding expressions html5ever
|
|
461
|
+
// lowercased while parsing (attr-name position).
|
|
462
|
+
html = crate::compiler::binding_case::restore(&html, &self.binding_cases.borrow());
|
|
463
|
+
|
|
444
464
|
html
|
|
445
465
|
}
|
|
446
466
|
}
|
|
@@ -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);
|
|
@@ -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
package/runtime/utils.js
CHANGED
|
@@ -221,7 +221,16 @@ export const evalInScope = (expr, state, element = null) => {
|
|
|
221
221
|
// `fx`. Bails on bracket/call expressions because those need a real
|
|
222
222
|
// evaluator (and `evalInScope` already handled them).
|
|
223
223
|
export const resolveCaseInsensitivePath = (state, path) => {
|
|
224
|
-
if (path.includes('[')
|
|
224
|
+
if (path.includes('[')) return undefined;
|
|
225
|
+
// Component prop substitution wraps the source in grouping parens, e.g.
|
|
226
|
+
// `props.element` inside a reused component becomes `(equipmentDetailProps).element`.
|
|
227
|
+
// The HTML parser lowercases name-binding attribute names, so strip the grouping
|
|
228
|
+
// parens to recover a plain dotted path; bail if anything but identifiers + dots
|
|
229
|
+
// survives (a real function call like `selectedEquipProps(uuid)` can't be recovered).
|
|
230
|
+
if (path.includes('(')) {
|
|
231
|
+
path = path.replace(/[()]/g, '');
|
|
232
|
+
if (!/^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/.test(path)) return undefined;
|
|
233
|
+
}
|
|
225
234
|
const segments = path.split('.');
|
|
226
235
|
let current = state;
|
|
227
236
|
for (const seg of segments) {
|