@ape-egg/vibe 2.1.3 → 2.1.6

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.
@@ -1,15 +1,24 @@
1
1
  use html5ever::parse_document;
2
2
  use html5ever::tendril::TendrilSink;
3
3
  use html5ever::serialize::{serialize, SerializeOpts};
4
- use markup5ever_rcdom::{RcDom, NodeData, Handle, SerializableHandle};
4
+ use markup5ever_rcdom::{RcDom, NodeData, Handle, Node, SerializableHandle};
5
5
  use markup5ever::{QualName, LocalName, Namespace};
6
6
  use regex::Regex;
7
7
  use serde_json::Value;
8
8
  use std::collections::HashSet;
9
9
  use std::path::PathBuf;
10
10
  use std::cell::RefCell;
11
+ use std::sync::OnceLock;
11
12
  use crate::compiler::state_extractor::StateExtractor;
12
13
 
14
+ /// Matches a `component(...)` state-registration call in a component's inline
15
+ /// script. The `\b` keeps it from firing on `subcomponent(`; resolvable or not,
16
+ /// its presence is what tells the tagger this wrapper owns component-local state.
17
+ fn component_call_regex() -> &'static Regex {
18
+ static RE: OnceLock<Regex> = OnceLock::new();
19
+ RE.get_or_init(|| Regex::new(r"\bcomponent\s*\(").unwrap())
20
+ }
21
+
13
22
  pub struct ComponentTagger;
14
23
 
15
24
  pub struct TaggedResult {
@@ -137,46 +146,74 @@ impl ComponentTagger {
137
146
  for child in node.children.borrow().iter() {
138
147
  if let NodeData::Element { name: ref child_name, .. } = child.data {
139
148
  if child_name.local.as_ref() == "script" {
140
- let mut script_bytes = Vec::new();
141
- let _ = serialize(
142
- &mut script_bytes,
143
- &SerializableHandle::from(child.clone()),
144
- SerializeOpts::default()
145
- );
146
- if let Ok(script_str) = String::from_utf8(script_bytes) {
147
- direct_scripts_html.push_str(&script_str);
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
+ }
148
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>");
149
169
  }
150
170
  }
151
171
  }
152
172
 
153
- if let Ok(Value::Object(comp_state)) = StateExtractor::extract_from_html(
154
- &direct_scripts_html,
155
- &PathBuf::from(".")
156
- ) {
157
- // Only tag and register components that have state
158
- if !comp_state.is_empty() {
159
- let component_id = format!("_c{}", *counter.borrow());
160
- *counter.borrow_mut() += 1;
161
-
162
- // Add data-vibe-component-id attribute
163
- let mut attrs_mut = attrs.borrow_mut();
164
- attrs_mut.push(markup5ever::Attribute {
165
- name: QualName::new(
166
- None,
167
- Namespace::from(""),
168
- LocalName::from("data-vibe-component-id"),
169
- ),
170
- value: component_id.clone().into(),
171
- });
172
- drop(attrs_mut);
173
-
174
- // Rewrite this.property to componentId.property in the node's children
175
- // This allows ValueStamper to properly evaluate component-scoped expressions
176
- Self::rewrite_this_to_component_id(node, &component_id);
177
-
178
- component_states.borrow_mut().push((component_id, Value::Object(comp_state)));
179
- }
173
+ // A component must be tagged whenever it REGISTERS local state via a
174
+ // `component(...)` call — that is what makes `this.X` in its markup
175
+ // resolvable at runtime. Whether the initial state VALUES are statically
176
+ // resolvable is a separate concern (it only governs FOUC value-stamping).
177
+ // A state built from a runtime expression — e.g.
178
+ // `component({ levels: Array.from({ length: 25 }, ...) })` — resolves to
179
+ // an empty object here, but the component still needs its id + `this.`
180
+ // rewrite, or a compiled `<!-- each this.levels -->` ships a raw `this.`
181
+ // the runtime can't resolve when a restored conditional branch re-parses
182
+ // the template (no wrapper-tag ancestor to fall back to).
183
+ let registers_state = component_call_regex().is_match(&direct_scripts_html);
184
+
185
+ if registers_state {
186
+ let comp_state = match StateExtractor::extract_from_html(
187
+ &direct_scripts_html,
188
+ &PathBuf::from(".")
189
+ ) {
190
+ Ok(Value::Object(state)) => state,
191
+ _ => serde_json::Map::new(),
192
+ };
193
+
194
+ let component_id = format!("_c{}", *counter.borrow());
195
+ *counter.borrow_mut() += 1;
196
+
197
+ // Add data-vibe-component-id attribute
198
+ let mut attrs_mut = attrs.borrow_mut();
199
+ attrs_mut.push(markup5ever::Attribute {
200
+ name: QualName::new(
201
+ None,
202
+ Namespace::from(""),
203
+ LocalName::from("data-vibe-component-id"),
204
+ ),
205
+ value: component_id.clone().into(),
206
+ });
207
+ drop(attrs_mut);
208
+
209
+ // Rewrite this.property to componentId.property in the node's children
210
+ // This allows ValueStamper to properly evaluate component-scoped expressions
211
+ Self::rewrite_this_to_component_id(node, &component_id);
212
+
213
+ // Register any statically-resolvable initial state under the id. May be
214
+ // empty (runtime-built state); the runtime fills it in when the inlined
215
+ // `component(...)` call runs on mount.
216
+ component_states.borrow_mut().push((component_id, Value::Object(comp_state)));
180
217
  }
181
218
  }
182
219
  }
@@ -200,36 +237,84 @@ impl ComponentTagger {
200
237
  })
201
238
  }
202
239
 
203
- /// Rewrite this.property to componentId.property in a node's subtree
240
+ /// Rewrite `this.X` to `componentId.X` throughout a component's subtree:
241
+ /// inside `@[...]` bindings (text + attributes, nested paths and multi-ref
242
+ /// expressions) and inside if/each/else-if directive comments. Resolving the
243
+ /// directives at build time is what lets a component-local `<!-- if this.x -->`
244
+ /// work on compiled (manifest-restored) pages, where the runtime can't fall
245
+ /// back to a `data-vibe-component-id` ancestor lookup.
204
246
  fn rewrite_this_to_component_id(node: &Handle, component_id: &str) {
205
247
  use regex::Regex;
206
- let this_regex = Regex::new(r"@\[this\.(\w+)\]").unwrap();
248
+ // The whole `@[...]` binding; `this.` is resolved within it so nested
249
+ // paths (this.x.y) and expressions (this.a + this.b) are all covered.
250
+ let binding_regex = Regex::new(r"@\[[^\]]*\]").unwrap();
251
+ let this_prop = Regex::new(r"\bthis\.").unwrap();
252
+ // `$.this.X` writes that live in event-handler bodies (onclick="$.this.mode
253
+ // = 'edit'"), outside any `@[...]`. The runtime lowers these via its
254
+ // STATE_THIS_PROP_REGEX pass (runtime/component.js); the compiler must do
255
+ // the same or compiled pages ship a literal `$.this.X` that resolves to
256
+ // undefined when the native handler fires.
257
+ let state_this_prop = Regex::new(r"\$\.this\.(\w+)").unwrap();
258
+ Self::rewrite_node_recursive(node, &binding_regex, &this_prop, &state_this_prop, component_id);
259
+ }
207
260
 
208
- // Recursively walk the node and all descendants
209
- Self::rewrite_node_recursive(node, &this_regex, component_id);
261
+ /// Resolve `this.` to `componentId.` inside every `@[...]` binding in a string.
262
+ fn rewrite_bindings(s: &str, binding_regex: &Regex, this_prop: &Regex, component_id: &str) -> String {
263
+ let replacement = format!("{}.", component_id);
264
+ binding_regex
265
+ .replace_all(s, |caps: &regex::Captures| {
266
+ this_prop.replace_all(&caps[0], replacement.as_str()).to_string()
267
+ })
268
+ .to_string()
210
269
  }
211
270
 
212
- /// Recursively rewrite this.property in text nodes and attributes
213
- fn rewrite_node_recursive(node: &Handle, regex: &Regex, component_id: &str) {
214
- // Rewrite text content
271
+ /// Recursively rewrite this.property in text, attributes, and directive comments
272
+ fn rewrite_node_recursive(node: &Handle, binding_regex: &Regex, this_prop: &Regex, state_this_prop: &Regex, component_id: &str) {
273
+ // Rewrite text content bindings
215
274
  if let NodeData::Text { ref contents } = node.data {
216
275
  let mut text = contents.borrow_mut();
217
- let new_text = regex.replace_all(&text, format!("@[{}.$1]", component_id));
218
- *text = new_text.to_string().into();
276
+ let new_text = Self::rewrite_bindings(&text, binding_regex, this_prop, component_id);
277
+ *text = new_text.into();
219
278
  }
220
279
 
221
- // Rewrite attributes
280
+ // Rewrite attribute bindings (`@[...]`) and event-handler `$.this.X` writes.
222
281
  if let NodeData::Element { ref attrs, .. } = node.data {
223
282
  let mut attrs_mut = attrs.borrow_mut();
224
283
  for attr in attrs_mut.iter_mut() {
225
- let new_value = regex.replace_all(&attr.value, format!("@[{}.$1]", component_id));
226
- attr.value = new_value.to_string().into();
284
+ let mut new_value = Self::rewrite_bindings(&attr.value, binding_regex, this_prop, component_id);
285
+ if new_value.contains("$.this.") {
286
+ new_value = state_this_prop
287
+ .replace_all(&new_value, |c: &regex::Captures| format!("$.{}.{}", component_id, &c[1]))
288
+ .to_string();
289
+ }
290
+ attr.value = new_value.into();
291
+ }
292
+ }
293
+
294
+ // Directive comments are immutable in the RcDom, so swap any if/each/else-if
295
+ // comment carrying a `this.` expression for a freshly-built comment node.
296
+ {
297
+ let mut children = node.children.borrow_mut();
298
+ for child in children.iter_mut() {
299
+ if let NodeData::Comment { ref contents } = child.data {
300
+ let text = contents.to_string();
301
+ let trimmed = text.trim_start();
302
+ let is_directive = trimmed.starts_with("if ")
303
+ || trimmed.starts_with("each ")
304
+ || trimmed.starts_with("else if ");
305
+ if is_directive && text.contains("this.") {
306
+ let new_text = this_prop
307
+ .replace_all(&text, format!("{}.", component_id).as_str())
308
+ .to_string();
309
+ *child = Node::new(NodeData::Comment { contents: new_text.into() });
310
+ }
311
+ }
227
312
  }
228
313
  }
229
314
 
230
315
  // Recurse into children
231
316
  for child in node.children.borrow().iter() {
232
- Self::rewrite_node_recursive(child, regex, component_id);
317
+ Self::rewrite_node_recursive(child, binding_regex, this_prop, state_this_prop, component_id);
233
318
  }
234
319
  }
235
320
  }
@@ -238,6 +323,10 @@ impl ComponentTagger {
238
323
  mod tests {
239
324
  use super::*;
240
325
 
326
+
327
+
328
+
329
+
241
330
  #[test]
242
331
  fn tag_single_component() {
243
332
  let html = r#"<!DOCTYPE html><html><body><component><script>component({ count: 0 })</script><div>@[this.count]</div></component></body></html>"#;
@@ -248,9 +337,69 @@ mod tests {
248
337
  assert!(result.html.contains("data-vibe-component-id=\"_c0\""));
249
338
  }
250
339
 
340
+ #[test]
341
+ fn rewrites_this_in_directive_comments_and_nested_bindings() {
342
+ // Conditionals/iterations keep `this.` in their directive comments and
343
+ // bindings can be nested (this.x.y). Compiled (manifest-restored) pages
344
+ // can't resolve `this.` via a runtime wrapper-tag lookup, so the compiler
345
+ // must resolve it to the component id at build time.
346
+ let html = r#"<!DOCTYPE html><html><body><component><script>const s = { x: null }; component(s);</script><page-content><!-- if this.x --><span>@[this.x.name]</span><!-- /if --><!-- each this.items as it --><b>@[it]</b><!-- /each --></page-content></component></body></html>"#;
347
+
348
+ let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
349
+
350
+ assert!(result.html.contains("if _c0.x"), "if comment not rewritten: {}", result.html);
351
+ assert!(result.html.contains("@[_c0.x.name]"), "nested binding not rewritten: {}", result.html);
352
+ assert!(result.html.contains("each _c0.items as it"), "each comment not rewritten: {}", result.html);
353
+ // A non-this global expression must be left alone.
354
+ assert!(!result.html.contains("_c0.items as _c0"), "over-rewrote loop alias: {}", result.html);
355
+ }
356
+
357
+ #[test]
358
+ fn tag_component_called_with_variable() {
359
+ // BrawlerDetailContent shape: state held in a `const`, passed to
360
+ // component(state) as a bare identifier, with a top-level
361
+ // `<!-- if this.X -->`. The wrapper must still be tagged so the
362
+ // conditional can resolve `this.` at runtime.
363
+ let html = r#"<!DOCTYPE html><html><body><component><script>const state = { currentCharacter: null }; component(state);</script><page-content><!-- if this.currentCharacter --><span>@[this.currentCharacter]</span><!-- /if --></page-content></component></body></html>"#;
364
+
365
+ let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
366
+
367
+ assert!(
368
+ result.html.contains("data-vibe-component-id=\"_c0\""),
369
+ "wrapper not tagged; html: {}",
370
+ result.html
371
+ );
372
+ }
373
+
374
+ #[test]
375
+ fn tags_component_with_runtime_built_state() {
376
+ // AccountProgression shape: the ONLY state key is built from a runtime
377
+ // expression (`Array.from`), so static extraction resolves to an empty
378
+ // object. The wrapper must still be tagged and its `<!-- each this.X -->`
379
+ // rewritten — otherwise a restored conditional branch re-parses a raw
380
+ // `this.levels` the runtime can't resolve, and the loop renders nothing.
381
+ let html = r#"<!DOCTYPE html><html><body><component><script>component({ levels: Array.from({ length: 25 }, (_, i) => i + 1) });</script><slides><!-- each this.levels as lvl --><slide>@[lvl]</slide><!-- /each --></slides></component></body></html>"#;
382
+
383
+ let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
384
+
385
+ assert!(
386
+ result.html.contains("data-vibe-component-id=\"_c0\""),
387
+ "runtime-built-state component not tagged: {}",
388
+ result.html
389
+ );
390
+ assert!(
391
+ result.html.contains("each _c0.levels as lvl"),
392
+ "each comment not rewritten: {}",
393
+ result.html
394
+ );
395
+ }
396
+
251
397
  #[test]
252
398
  fn tag_multiple_components() {
253
- let html = r#"<!DOCTYPE html><html><body><component></component><component></component><component></component></body></html>"#;
399
+ // Each wrapper registers component-local state, so all three are tagged
400
+ // with sequential ids. (A bare `<component></component>` with no state
401
+ // call is intentionally NOT tagged — it owns no `this.` to resolve.)
402
+ let html = r#"<!DOCTYPE html><html><body><component><script>component({ a: 1 })</script></component><component><script>component({ b: 2 })</script></component><component><script>component({ c: 3 })</script></component></body></html>"#;
254
403
 
255
404
  let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
256
405
 
@@ -9,17 +9,20 @@ use std::fs;
9
9
 
10
10
  /// Analyzes JavaScript code to extract state from vibe() calls
11
11
  pub struct JsAnalyzer {
12
- source_map: Lrc<SourceMap>,
13
12
  base_path: PathBuf,
14
13
  _module_cache: HashMap<PathBuf, Module>,
14
+ /// Memoizes resolved imports by (file, export name). Dedupes diamond imports
15
+ /// (without it, recursive resolution re-parses shared modules exponentially)
16
+ /// and breaks cycles (an in-progress entry is seeded `None`).
17
+ import_cache: HashMap<(PathBuf, String), Option<Value>>,
15
18
  }
16
19
 
17
20
  impl JsAnalyzer {
18
21
  pub fn new(base_path: PathBuf) -> Self {
19
22
  Self {
20
- source_map: Lrc::new(SourceMap::default()),
21
23
  base_path,
22
24
  _module_cache: HashMap::new(),
25
+ import_cache: HashMap::new(),
23
26
  }
24
27
  }
25
28
 
@@ -34,11 +37,11 @@ impl JsAnalyzer {
34
37
  module.visit_with(&mut scope_builder);
35
38
  // eprintln!("[JsAnalyzer] Found {} imports", scope_builder.imports.len());
36
39
 
37
- // Resolve imports to actual Values
40
+ // Resolve imports to actual Values (keyed by the LOCAL name used in code)
38
41
  let mut resolved_imports: HashMap<String, Value> = HashMap::new();
39
- for (import_name, import_path) in &scope_builder.imports {
40
- if let Some(value) = self.resolve_import_value(&import_path, import_name) {
41
- resolved_imports.insert(import_name.clone(), value);
42
+ for (local_name, (import_path, imported_name)) in scope_builder.imports.clone() {
43
+ if let Some(value) = self.resolve_import_value(&import_path, &imported_name) {
44
+ resolved_imports.insert(local_name, value);
42
45
  }
43
46
  }
44
47
 
@@ -50,35 +53,73 @@ impl JsAnalyzer {
50
53
  state_extractor.merged_state
51
54
  }
52
55
 
53
- /// Resolve an import and return the exported value (fully resolved)
54
- fn resolve_import_value(&mut self, import_path: &str, import_name: &str) -> Option<Value> {
55
- // eprintln!("[JsAnalyzer] Resolving import: '{}' name: '{}'", import_path, import_name);
56
- // Resolve relative path
56
+ /// Resolve an import and return the exported value (fully resolved).
57
+ /// `imported_name` is the EXPORT name to look up ("default" for a default
58
+ /// import). The imported module's OWN imports are resolved too, so a chain
59
+ /// like `import appState` → `export default { version: VERSION }` →
60
+ /// `import VERSION from './version.js'` resolves all the way down.
61
+ fn resolve_import_value(&mut self, import_path: &str, imported_name: &str) -> Option<Value> {
62
+ self.resolve_import_value_depth(import_path, imported_name, 0)
63
+ }
64
+
65
+ fn resolve_import_value_depth(
66
+ &mut self,
67
+ import_path: &str,
68
+ imported_name: &str,
69
+ depth: usize,
70
+ ) -> Option<Value> {
71
+ // Guard against pathological depth.
72
+ if depth > 16 {
73
+ return None;
74
+ }
75
+
57
76
  let resolved_path = self.resolve_path(import_path)?;
58
- // eprintln!("[JsAnalyzer] Resolved to: {:?}", resolved_path);
77
+ let cache_key = (resolved_path.clone(), imported_name.to_string());
78
+ if let Some(cached) = self.import_cache.get(&cache_key) {
79
+ return cached.clone();
80
+ }
81
+ // Seed the in-progress entry so an import cycle resolves to None instead
82
+ // of recursing forever.
83
+ self.import_cache.insert(cache_key.clone(), None);
59
84
 
60
- // Read and parse the module
61
- let code = fs::read_to_string(&resolved_path).ok()?;
85
+ let result = self.resolve_export_from_file(&resolved_path, imported_name, depth);
86
+ self.import_cache.insert(cache_key, result.clone());
87
+ result
88
+ }
89
+
90
+ fn resolve_export_from_file(
91
+ &mut self,
92
+ resolved_path: &PathBuf,
93
+ imported_name: &str,
94
+ depth: usize,
95
+ ) -> Option<Value> {
96
+ let code = fs::read_to_string(resolved_path).ok()?;
62
97
  let module = self.parse_module(&code)?;
63
98
 
64
- // Build scope for the imported module
99
+ // Build scope for the imported module.
65
100
  let mut scope_builder = ScopeBuilder::new(resolved_path.parent()?.to_path_buf());
66
101
  module.visit_with(&mut scope_builder);
67
102
 
68
- // Find the exported binding
69
- let mut export_finder = ExportFinder::new(
70
- import_name.to_string(),
71
- scope_builder.bindings.clone(),
72
- );
73
- module.visit_with(&mut export_finder);
74
-
75
- // Resolve the exported expression with the file's bindings
76
- if let Some(expr) = export_finder.found_export {
77
- let state_extractor = StateExtractor::new(scope_builder.bindings);
78
- return state_extractor.resolve_expr(&expr);
103
+ // Recursively resolve THIS module's own imports so a property like
104
+ // `version: VERSION` (itself a default import) can be resolved. Import
105
+ // paths in this codebase are absolute (`/js/...`), resolved against the
106
+ // analyzer's fixed base, so no per-module base swap is needed.
107
+ let mut resolved_imports: HashMap<String, Value> = HashMap::new();
108
+ for (local_name, (path, name)) in scope_builder.imports.clone() {
109
+ if let Some(value) = self.resolve_import_value_depth(&path, &name, depth + 1) {
110
+ resolved_imports.insert(local_name, value);
111
+ }
79
112
  }
80
113
 
81
- None
114
+ // Find the requested export.
115
+ let mut export_finder =
116
+ ExportFinder::new(imported_name.to_string(), scope_builder.bindings.clone());
117
+ module.visit_with(&mut export_finder);
118
+
119
+ let expr = export_finder.found_export?;
120
+ let mut state_extractor = StateExtractor::new(scope_builder.bindings);
121
+ state_extractor.resolved_imports = resolved_imports;
122
+ state_extractor.resolve_expr(&expr)
82
123
  }
83
124
 
84
125
  /// Resolve import path relative to base_path
@@ -115,9 +156,12 @@ impl JsAnalyzer {
115
156
  }
116
157
  }
117
158
 
118
- /// Parse JavaScript/TypeScript module
159
+ /// Parse JavaScript/TypeScript module. Uses a FRESH source map per call —
160
+ /// recursive import resolution parses many files, and a shared map would
161
+ /// accumulate byte positions until they overflow (`start <= end` panic).
119
162
  fn parse_module(&self, code: &str) -> Option<Module> {
120
- let fm = self.source_map.new_source_file(
163
+ let source_map: Lrc<SourceMap> = Default::default();
164
+ let fm = source_map.new_source_file(
121
165
  Lrc::new(FileName::Anon),
122
166
  code.to_string(),
123
167
  );
@@ -147,7 +191,9 @@ impl JsAnalyzer {
147
191
  /// Builds a scope of variable bindings and tracks imports
148
192
  struct ScopeBuilder {
149
193
  bindings: HashMap<String, Expr>,
150
- imports: HashMap<String, String>, // import_name -> import_path
194
+ // local binding name -> (import path, imported export name; "default" for a
195
+ // default import, "*" for a namespace import)
196
+ imports: HashMap<String, (String, String)>,
151
197
  _base_path: PathBuf,
152
198
  }
153
199
 
@@ -167,21 +213,29 @@ impl Visit for ScopeBuilder {
167
213
 
168
214
  for specifier in &import.specifiers {
169
215
  match specifier {
170
- // import { name } from './file.js'
216
+ // import { orig as local } from './file.js'
171
217
  ImportSpecifier::Named(named) => {
172
- let _import_name = match &named.imported {
218
+ let imported = match &named.imported {
173
219
  Some(ModuleExportName::Ident(ident)) => ident.sym.to_string(),
174
- _ => named.local.sym.to_string(),
220
+ Some(ModuleExportName::Str(s)) => s.value.to_string(),
221
+ None => named.local.sym.to_string(),
175
222
  };
176
- self.imports.insert(named.local.sym.to_string(), import_path.clone());
223
+ self.imports
224
+ .insert(named.local.sym.to_string(), (import_path.clone(), imported));
177
225
  }
178
226
  // import name from './file.js' (default import)
179
227
  ImportSpecifier::Default(default) => {
180
- self.imports.insert(default.local.sym.to_string(), import_path.clone());
228
+ self.imports.insert(
229
+ default.local.sym.to_string(),
230
+ (import_path.clone(), "default".to_string()),
231
+ );
181
232
  }
182
- // import * as name from './file.js'
233
+ // import * as name from './file.js' (namespace import)
183
234
  ImportSpecifier::Namespace(namespace) => {
184
- self.imports.insert(namespace.local.sym.to_string(), import_path.clone());
235
+ self.imports.insert(
236
+ namespace.local.sym.to_string(),
237
+ (import_path.clone(), "*".to_string()),
238
+ );
185
239
  }
186
240
  }
187
241
  }
@@ -233,6 +287,13 @@ impl ExportFinder {
233
287
  }
234
288
 
235
289
  impl Visit for ExportFinder {
290
+ // `export default <expr>` — matched when the consumer used a default import.
291
+ fn visit_export_default_expr(&mut self, export: &ExportDefaultExpr) {
292
+ if self.target_name == "default" {
293
+ self.found_export = Some((*export.expr).clone());
294
+ }
295
+ }
296
+
236
297
  fn visit_export_decl(&mut self, export: &ExportDecl) {
237
298
  match &export.decl {
238
299
  Decl::Var(var_decl) => {
@@ -554,6 +615,63 @@ mod tests {
554
615
  assert_eq!(state["c"].as_f64().unwrap(), 3.0);
555
616
  }
556
617
 
618
+ #[test]
619
+ fn test_component_called_with_variable() {
620
+ // component(state) — the whole argument is a variable bound to an object
621
+ // literal above (BrawlerDetailContent does exactly this). The extractor
622
+ // must resolve the variable; otherwise the component wrapper is never
623
+ // tagged and its `this.` conditionals can't resolve at runtime.
624
+ let mut analyzer = JsAnalyzer::new(PathBuf::from("."));
625
+ let script = r#"
626
+ const state = {
627
+ currentCharacter: null,
628
+ currentSlots: [],
629
+ currentLastTickEnd: 0,
630
+ };
631
+ setupCharacter(state);
632
+ const id = component(state);
633
+ "#;
634
+
635
+ let state = analyzer.extract_state(script).expect("state should be extracted");
636
+ assert!(state.get("currentCharacter").is_some(), "got: {state}");
637
+ assert_eq!(state["currentLastTickEnd"].as_f64().unwrap(), 0.0);
638
+ }
639
+
640
+ #[test]
641
+ fn test_component_var_amid_realistic_script() {
642
+ // Mirrors BrawlerDetailContent's script shape: top-level imports, a
643
+ // dynamic import().then(), an empty `catch {}`, optional chaining, and
644
+ // `component(state)`. A parse failure on any of these makes extract_state
645
+ // return None and the component goes untagged.
646
+ let mut analyzer = JsAnalyzer::new(PathBuf::from("."));
647
+ let script = r#"
648
+ import CHARACTERS from '/js/constants/CHARACTERS.js';
649
+ import { buildEquipmentSlots, unequip } from '/js/equipment.js';
650
+
651
+ const backParam = new URLSearchParams(location.search).get('back') || '';
652
+ const state = {
653
+ currentCharacter: null,
654
+ currentSlots: [],
655
+ currentLastTickEnd: 0,
656
+ backUrl: backParam.startsWith('/') ? backParam : '',
657
+ };
658
+
659
+ const setupCharacter = (target) => {
660
+ const ref = $.characters?.[0];
661
+ try { ref.foo(); } catch {}
662
+ target.currentCharacter = ref;
663
+ };
664
+
665
+ setupCharacter(state);
666
+ const id = component(state);
667
+
668
+ import('/js/dnd.js').then(({ default: dnd }) => { dnd.init(); });
669
+ "#;
670
+
671
+ let state = analyzer.extract_state(script).expect("state should be extracted");
672
+ assert!(state.get("currentCharacter").is_some(), "got: {state}");
673
+ }
674
+
557
675
  #[test]
558
676
  fn test_absolute_path_import() {
559
677
  let mut analyzer = JsAnalyzer::new(PathBuf::from("/tmp"));
@@ -569,4 +687,29 @@ mod tests {
569
687
  assert_eq!(state["other"].as_str().unwrap(), "value");
570
688
  assert_eq!(state["extra"].as_str().unwrap(), "value");
571
689
  }
690
+
691
+ #[test]
692
+ fn resolves_default_import_chain() {
693
+ // Two nested DEFAULT imports, mirroring boot.js → app.js → version.js:
694
+ // `import appState` → `export default { version: VERSION }` → `import VERSION`.
695
+ let dir = std::env::temp_dir().join("vibe_default_import_chain_test");
696
+ let _ = fs::remove_dir_all(&dir);
697
+ fs::create_dir_all(&dir).unwrap();
698
+ fs::write(dir.join("version.js"), "export default '0.1.5';").unwrap();
699
+ fs::write(
700
+ dir.join("app.js"),
701
+ "import VERSION from '/version.js';\nexport default { version: VERSION, coins: 400 };",
702
+ )
703
+ .unwrap();
704
+
705
+ let mut analyzer = JsAnalyzer::new(dir.clone());
706
+ let state = analyzer
707
+ .extract_state("import appState from '/app.js';\nvibe({ ...appState });")
708
+ .unwrap();
709
+
710
+ assert_eq!(state["version"], "0.1.5");
711
+ assert_eq!(state["coins"].as_f64(), Some(400.0));
712
+
713
+ let _ = fs::remove_dir_all(&dir);
714
+ }
572
715
  }
@@ -5,6 +5,7 @@ mod value_stamper;
5
5
  mod iteration_optimizer;
6
6
  mod js_analyzer;
7
7
  mod component_tagger;
8
+ mod reassignment_analyzer;
8
9
  pub mod watcher;
9
10
 
10
11
  pub use compile::Compiler;