@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.
- package/CHANGELOG.md +26 -0
- package/README.md +48 -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 +198 -8
- package/compiler/src/compiler/component_tagger.rs +199 -50
- package/compiler/src/compiler/js_analyzer.rs +179 -36
- package/compiler/src/compiler/mod.rs +1 -0
- package/compiler/src/compiler/reassignment_analyzer.rs +456 -0
- package/compiler/src/compiler/value_stamper.rs +128 -16
- package/compiler/src/parser/html.rs +114 -4
- package/package.json +1 -1
- package/runtime/pre-compiled-manifest.js +101 -60
- package/runtime/pre-compiled-manifest.test.mjs +58 -0
|
@@ -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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
)
|
|
146
|
-
|
|
147
|
-
|
|
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
|
+
}
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
)
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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: ®ex::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
|
|
213
|
-
fn rewrite_node_recursive(node: &Handle,
|
|
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 =
|
|
218
|
-
*text = new_text.
|
|
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
|
|
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 =
|
|
226
|
-
|
|
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: ®ex::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,
|
|
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
|
-
|
|
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 (
|
|
40
|
-
if let Some(value) = self.resolve_import_value(&import_path,
|
|
41
|
-
resolved_imports.insert(
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
216
|
+
// import { orig as local } from './file.js'
|
|
171
217
|
ImportSpecifier::Named(named) => {
|
|
172
|
-
let
|
|
218
|
+
let imported = match &named.imported {
|
|
173
219
|
Some(ModuleExportName::Ident(ident)) => ident.sym.to_string(),
|
|
174
|
-
|
|
220
|
+
Some(ModuleExportName::Str(s)) => s.value.to_string(),
|
|
221
|
+
None => named.local.sym.to_string(),
|
|
175
222
|
};
|
|
176
|
-
self.imports
|
|
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(
|
|
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(
|
|
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
|
}
|