@ape-egg/vibe 2.1.22 → 3.0.0

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.
Files changed (56) hide show
  1. package/README.md +112 -5
  2. package/boot.js +4 -4
  3. package/component.js +27 -29
  4. package/hot-module-refresh.js +4 -4
  5. package/index.js +26 -17
  6. package/llms.txt +36 -5
  7. package/package.json +20 -14
  8. package/runtime/affected.js +159 -36
  9. package/runtime/cleanup.js +45 -1
  10. package/runtime/component.js +360 -98
  11. package/runtime/conditionals.js +111 -14
  12. package/runtime/debug.js +24 -0
  13. package/runtime/dispatch.js +172 -0
  14. package/runtime/hydrate.js +277 -110
  15. package/runtime/index.js +189 -65
  16. package/runtime/iterate.js +125 -50
  17. package/runtime/iteration-utils.js +59 -8
  18. package/runtime/manifest.js +77 -2
  19. package/runtime/parse.js +81 -11
  20. package/runtime/pre-compiled-iterations.js +19 -6
  21. package/runtime/pre-compiled-manifest.js +13 -4
  22. package/runtime/staging.js +153 -0
  23. package/runtime/state.js +31 -0
  24. package/runtime/tracking.js +173 -0
  25. package/runtime/utils.js +155 -78
  26. package/spa.js +206 -0
  27. package/vibe.css +8 -4
  28. package/CHANGELOG.md +0 -1159
  29. package/ROADMAP.md +0 -397
  30. package/compiler/bin/vibe-compile.js +0 -121
  31. package/compiler/native/.gitkeep +0 -0
  32. package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
  33. package/compiler/native/vibe-compiler-linux-x64 +0 -0
  34. package/compiler/src/Cargo.lock +0 -2023
  35. package/compiler/src/Cargo.toml +0 -38
  36. package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +0 -241
  37. package/compiler/src/compiler/binding_case.rs +0 -88
  38. package/compiler/src/compiler/compile.rs +0 -2522
  39. package/compiler/src/compiler/component_tagger.rs +0 -469
  40. package/compiler/src/compiler/iteration_optimizer.rs +0 -455
  41. package/compiler/src/compiler/js_analyzer.rs +0 -715
  42. package/compiler/src/compiler/manifest_builder.rs +0 -693
  43. package/compiler/src/compiler/mod.rs +0 -15
  44. package/compiler/src/compiler/name_binding_protect.rs +0 -207
  45. package/compiler/src/compiler/reassignment_analyzer.rs +0 -456
  46. package/compiler/src/compiler/state_extractor.rs +0 -263
  47. package/compiler/src/compiler/value_stamper.rs +0 -921
  48. package/compiler/src/compiler/watcher.rs +0 -1147
  49. package/compiler/src/config.rs +0 -239
  50. package/compiler/src/main.rs +0 -347
  51. package/compiler/src/parser/element.rs +0 -96
  52. package/compiler/src/parser/html.rs +0 -1004
  53. package/compiler/src/parser/mod.rs +0 -8
  54. package/runtime/pre-compiled-manifest.test.mjs +0 -58
  55. package/runtime/scope.js +0 -50
  56. package/test-results/.last-run.json +0 -4
@@ -1,715 +0,0 @@
1
- use swc_common::{sync::Lrc, SourceMap, FileName};
2
- use swc_ecma_parser::{Parser, StringInput, Syntax, EsSyntax};
3
- use swc_ecma_ast::*;
4
- use swc_ecma_visit::{Visit, VisitWith};
5
- use serde_json::{Value, Map};
6
- use std::collections::HashMap;
7
- use std::path::PathBuf;
8
- use std::fs;
9
-
10
- /// Analyzes JavaScript code to extract state from vibe() calls
11
- pub struct JsAnalyzer {
12
- base_path: PathBuf,
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>>,
18
- }
19
-
20
- impl JsAnalyzer {
21
- pub fn new(base_path: PathBuf) -> Self {
22
- Self {
23
- base_path,
24
- _module_cache: HashMap::new(),
25
- import_cache: HashMap::new(),
26
- }
27
- }
28
-
29
- /// Extract state from a JavaScript/TypeScript script
30
- pub fn extract_state(&mut self, script: &str) -> Option<Value> {
31
- // eprintln!("[JsAnalyzer] extract_state called, script length: {}", script.len());
32
- let module = self.parse_module(script)?;
33
- // eprintln!("[JsAnalyzer] Module parsed");
34
-
35
- // Build scope with import resolution
36
- let mut scope_builder = ScopeBuilder::new(self.base_path.clone());
37
- module.visit_with(&mut scope_builder);
38
- // eprintln!("[JsAnalyzer] Found {} imports", scope_builder.imports.len());
39
-
40
- // Resolve imports to actual Values (keyed by the LOCAL name used in code)
41
- let mut resolved_imports: HashMap<String, Value> = HashMap::new();
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);
45
- }
46
- }
47
-
48
- // Find vibe() calls and resolve their arguments
49
- let mut state_extractor = StateExtractor::new(scope_builder.bindings);
50
- state_extractor.resolved_imports = resolved_imports;
51
- module.visit_with(&mut state_extractor);
52
-
53
- state_extractor.merged_state
54
- }
55
-
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
-
76
- let resolved_path = self.resolve_path(import_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);
84
-
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()?;
97
- let module = self.parse_module(&code)?;
98
-
99
- // Build scope for the imported module.
100
- let mut scope_builder = ScopeBuilder::new(resolved_path.parent()?.to_path_buf());
101
- module.visit_with(&mut scope_builder);
102
-
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
- }
112
- }
113
-
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)
123
- }
124
-
125
- /// Resolve import path relative to base_path
126
- fn resolve_path(&self, import_path: &str) -> Option<PathBuf> {
127
- // eprintln!("[JsAnalyzer] Resolving path: '{}' from base: '{:?}'", import_path, self.base_path);
128
- let resolved = if import_path.starts_with('/') {
129
- // Absolute path: treat as relative to base_path root
130
- let clean_path = import_path.strip_prefix('/').unwrap_or(import_path);
131
- let candidate = self.base_path.join(clean_path);
132
- // eprintln!("[JsAnalyzer] Candidate absolute path: {:?}", candidate);
133
- candidate
134
- } else if import_path.starts_with("./") || import_path.starts_with("../") {
135
- // Relative path
136
- self.base_path.join(import_path)
137
- } else {
138
- // Neither absolute nor relative - treat as relative
139
- self.base_path.join(import_path)
140
- };
141
-
142
- // Try with and without .js extension
143
- if resolved.exists() {
144
- // eprintln!("[JsAnalyzer] Path exists: {:?}", resolved);
145
- Some(resolved)
146
- } else {
147
- // eprintln!("[JsAnalyzer] Path doesn't exist, trying with .js extension");
148
- let with_js = resolved.with_extension("js");
149
- if with_js.exists() {
150
- // eprintln!("[JsAnalyzer] Path with .js exists: {:?}", with_js);
151
- Some(with_js)
152
- } else {
153
- // eprintln!("[JsAnalyzer] Path not found: {:?}", resolved);
154
- None
155
- }
156
- }
157
- }
158
-
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).
162
- fn parse_module(&self, code: &str) -> Option<Module> {
163
- let source_map: Lrc<SourceMap> = Default::default();
164
- let fm = source_map.new_source_file(
165
- Lrc::new(FileName::Anon),
166
- code.to_string(),
167
- );
168
-
169
- let syntax = Syntax::Es(EsSyntax {
170
- jsx: false,
171
- decorators: true,
172
- ..Default::default()
173
- });
174
-
175
- let input = StringInput::new(
176
- &fm.src,
177
- fm.start_pos,
178
- fm.end_pos,
179
- );
180
-
181
- let mut parser = Parser::new(
182
- syntax,
183
- input,
184
- None,
185
- );
186
-
187
- parser.parse_module().ok()
188
- }
189
- }
190
-
191
- /// Builds a scope of variable bindings and tracks imports
192
- struct ScopeBuilder {
193
- bindings: HashMap<String, Expr>,
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)>,
197
- _base_path: PathBuf,
198
- }
199
-
200
- impl ScopeBuilder {
201
- fn new(base_path: PathBuf) -> Self {
202
- Self {
203
- bindings: HashMap::new(),
204
- imports: HashMap::new(),
205
- _base_path: base_path,
206
- }
207
- }
208
- }
209
-
210
- impl Visit for ScopeBuilder {
211
- fn visit_import_decl(&mut self, import: &ImportDecl) {
212
- let import_path = import.src.value.to_string();
213
-
214
- for specifier in &import.specifiers {
215
- match specifier {
216
- // import { orig as local } from './file.js'
217
- ImportSpecifier::Named(named) => {
218
- let imported = match &named.imported {
219
- Some(ModuleExportName::Ident(ident)) => ident.sym.to_string(),
220
- Some(ModuleExportName::Str(s)) => s.value.to_string(),
221
- None => named.local.sym.to_string(),
222
- };
223
- self.imports
224
- .insert(named.local.sym.to_string(), (import_path.clone(), imported));
225
- }
226
- // import name from './file.js' (default import)
227
- ImportSpecifier::Default(default) => {
228
- self.imports.insert(
229
- default.local.sym.to_string(),
230
- (import_path.clone(), "default".to_string()),
231
- );
232
- }
233
- // import * as name from './file.js' (namespace import)
234
- ImportSpecifier::Namespace(namespace) => {
235
- self.imports.insert(
236
- namespace.local.sym.to_string(),
237
- (import_path.clone(), "*".to_string()),
238
- );
239
- }
240
- }
241
- }
242
-
243
- import.visit_children_with(self);
244
- }
245
-
246
- fn visit_export_decl(&mut self, export: &ExportDecl) {
247
- // Handle: export const name = value
248
- match &export.decl {
249
- Decl::Var(var_decl) => {
250
- self.visit_var_decl(var_decl);
251
- }
252
- _ => {}
253
- }
254
- export.visit_children_with(self);
255
- }
256
-
257
- fn visit_var_decl(&mut self, decl: &VarDecl) {
258
- for declarator in &decl.decls {
259
- if let Pat::Ident(ident) = &declarator.name {
260
- if let Some(init) = &declarator.init {
261
- self.bindings.insert(
262
- ident.id.sym.to_string(),
263
- (**init).clone(),
264
- );
265
- }
266
- }
267
- }
268
- decl.visit_children_with(self);
269
- }
270
- }
271
-
272
- /// Finds exported bindings in a module
273
- struct ExportFinder {
274
- target_name: String,
275
- bindings: HashMap<String, Expr>,
276
- found_export: Option<Expr>,
277
- }
278
-
279
- impl ExportFinder {
280
- fn new(target_name: String, bindings: HashMap<String, Expr>) -> Self {
281
- Self {
282
- target_name,
283
- bindings,
284
- found_export: None,
285
- }
286
- }
287
- }
288
-
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
-
297
- fn visit_export_decl(&mut self, export: &ExportDecl) {
298
- match &export.decl {
299
- Decl::Var(var_decl) => {
300
- for declarator in &var_decl.decls {
301
- if let Pat::Ident(ident) = &declarator.name {
302
- if ident.id.sym.to_string() == self.target_name {
303
- if let Some(init) = &declarator.init {
304
- self.found_export = Some((**init).clone());
305
- return;
306
- }
307
- }
308
- }
309
- }
310
- }
311
- _ => {}
312
- }
313
- export.visit_children_with(self);
314
- }
315
-
316
- fn visit_named_export(&mut self, export: &NamedExport) {
317
- // Handle: export { name }
318
- for specifier in &export.specifiers {
319
- if let ExportSpecifier::Named(named) = specifier {
320
- // Get the local name (what it's called in this file)
321
- let local_name = if let ModuleExportName::Ident(ident) = &named.orig {
322
- ident.sym.to_string()
323
- } else {
324
- continue;
325
- };
326
-
327
- // Get the export name (what it's called when imported)
328
- let export_name = match &named.exported {
329
- Some(ModuleExportName::Ident(ident)) => ident.sym.to_string(),
330
- _ => local_name.clone(),
331
- };
332
-
333
- // Check if this is the export we're looking for
334
- if export_name == self.target_name {
335
- // Look up the binding
336
- if let Some(binding) = self.bindings.get(&local_name) {
337
- self.found_export = Some(binding.clone());
338
- return;
339
- }
340
- }
341
- }
342
- }
343
- export.visit_children_with(self);
344
- }
345
- }
346
-
347
- /// Extracts state from vibe()/state()/component() calls
348
- struct StateExtractor {
349
- bindings: HashMap<String, Expr>,
350
- resolved_imports: HashMap<String, Value>,
351
- merged_state: Option<Value>,
352
- }
353
-
354
- impl StateExtractor {
355
- fn new(bindings: HashMap<String, Expr>) -> Self {
356
- Self {
357
- bindings,
358
- resolved_imports: HashMap::new(),
359
- merged_state: None,
360
- }
361
- }
362
-
363
- /// Resolve an expression to a JSON value
364
- fn resolve_expr(&self, expr: &Expr) -> Option<Value> {
365
- match expr {
366
- // Object literal: { a: 1, b: 2 }
367
- Expr::Object(obj) => self.resolve_object(obj),
368
-
369
- // Array literal: [1, 2, 3]
370
- Expr::Array(arr) => self.resolve_array(arr),
371
-
372
- // String literal: "hello"
373
- Expr::Lit(Lit::Str(s)) => Some(Value::String(s.value.to_string())),
374
-
375
- // Number literal: 42
376
- Expr::Lit(Lit::Num(n)) => {
377
- serde_json::Number::from_f64(n.value)
378
- .map(Value::Number)
379
- }
380
-
381
- // Boolean literal: true/false
382
- Expr::Lit(Lit::Bool(b)) => Some(Value::Bool(b.value)),
383
-
384
- // Null literal
385
- Expr::Lit(Lit::Null(_)) => Some(Value::Null),
386
-
387
- // Identifier: resolve from imports first, then bindings
388
- Expr::Ident(ident) => {
389
- let name = ident.sym.to_string();
390
-
391
- // Check resolved imports first (these are fully resolved Values)
392
- if let Some(value) = self.resolved_imports.get(&name) {
393
- return Some(value.clone());
394
- }
395
-
396
- // Fall back to bindings (these are Exprs that need resolution)
397
- self.bindings.get(&name)
398
- .and_then(|expr| self.resolve_expr(expr))
399
- }
400
-
401
- // Parenthesized expression: (expr)
402
- Expr::Paren(paren) => self.resolve_expr(&paren.expr),
403
-
404
- _ => None, // Can't statically resolve
405
- }
406
- }
407
-
408
- /// Resolve object literal with spread support
409
- /// Uses partial resolution: resolves what it can, skips what it can't
410
- fn resolve_object(&self, obj: &ObjectLit) -> Option<Value> {
411
- let mut result = Map::new();
412
-
413
- for prop in &obj.props {
414
- match prop {
415
- // Regular property: a: 1
416
- PropOrSpread::Prop(prop) => {
417
- if let Prop::KeyValue(kv) = &**prop {
418
- if let Some(key) = self.get_prop_key(&kv.key) {
419
- if let Some(value) = self.resolve_expr(&kv.value) {
420
- result.insert(key, value);
421
- }
422
- // Skip properties that can't be resolved
423
- }
424
- } else if let Prop::Shorthand(ident) = &**prop {
425
- // Shorthand: { a } where a is a variable
426
- let key = ident.sym.to_string();
427
- if let Some(expr) = self.bindings.get(&key) {
428
- if let Some(value) = self.resolve_expr(expr) {
429
- result.insert(key, value);
430
- }
431
- }
432
- // Skip if can't resolve
433
- }
434
- }
435
-
436
- // Spread: ...other
437
- PropOrSpread::Spread(spread) => {
438
- // Resolve the spread expression
439
- if let Some(Value::Object(spread_obj)) = self.resolve_expr(&spread.expr) {
440
- // Merge spread properties
441
- for (k, v) in spread_obj {
442
- result.insert(k, v);
443
- }
444
- }
445
- // Skip spreads that can't be resolved
446
- }
447
- }
448
- }
449
-
450
- // Return partial object even if some properties couldn't be resolved
451
- if result.is_empty() {
452
- None
453
- } else {
454
- Some(Value::Object(result))
455
- }
456
- }
457
-
458
- /// Resolve array literal
459
- /// Uses partial resolution: includes elements that can be resolved, skips others
460
- fn resolve_array(&self, arr: &ArrayLit) -> Option<Value> {
461
- let mut result = Vec::new();
462
-
463
- for elem in &arr.elems {
464
- if let Some(elem) = elem {
465
- match elem {
466
- // Regular element
467
- ExprOrSpread { spread: None, expr } => {
468
- if let Some(value) = self.resolve_expr(expr) {
469
- result.push(value);
470
- }
471
- // Skip elements that can't be resolved
472
- }
473
- // Spread element: ...arr
474
- ExprOrSpread { spread: Some(_), expr } => {
475
- if let Some(Value::Array(spread_arr)) = self.resolve_expr(expr) {
476
- result.extend(spread_arr);
477
- }
478
- // Skip spreads that can't be resolved
479
- }
480
- }
481
- }
482
- }
483
-
484
- // Return partial array even if some elements couldn't be resolved
485
- if result.is_empty() {
486
- None
487
- } else {
488
- Some(Value::Array(result))
489
- }
490
- }
491
-
492
- /// Get string key from property name
493
- fn get_prop_key(&self, key: &PropName) -> Option<String> {
494
- match key {
495
- PropName::Ident(ident) => Some(ident.sym.to_string()),
496
- PropName::Str(s) => Some(s.value.to_string()),
497
- _ => None,
498
- }
499
- }
500
-
501
- /// Merge state from a vibe() call
502
- fn merge_state(&mut self, state: Value) {
503
- if let Value::Object(new_state) = state {
504
- match &mut self.merged_state {
505
- Some(Value::Object(existing)) => {
506
- for (k, v) in new_state {
507
- existing.insert(k, v);
508
- }
509
- }
510
- _ => {
511
- self.merged_state = Some(Value::Object(new_state));
512
- }
513
- }
514
- }
515
- }
516
- }
517
-
518
- impl Visit for StateExtractor {
519
- fn visit_call_expr(&mut self, call: &CallExpr) {
520
- // Check if this is a vibe() or state() call
521
- if let Callee::Expr(expr) = &call.callee {
522
- if let Expr::Ident(ident) = &**expr {
523
- let name = ident.sym.to_string();
524
- if name == "vibe" || name == "state" || name == "component" {
525
- // Get first argument (the state object)
526
- if let Some(arg) = call.args.first() {
527
- if let Some(state) = self.resolve_expr(&arg.expr) {
528
- self.merge_state(state);
529
- }
530
- }
531
- }
532
- }
533
- }
534
-
535
- call.visit_children_with(self);
536
- }
537
- }
538
-
539
- #[cfg(test)]
540
- mod tests {
541
- use super::*;
542
- use serde_json::json;
543
-
544
- #[test]
545
- fn test_simple_object() {
546
- let mut analyzer = JsAnalyzer::new(PathBuf::from("."));
547
- let script = r#"
548
- vibe({ count: 0, name: "test" });
549
- "#;
550
-
551
- let state = analyzer.extract_state(script).unwrap();
552
- assert_eq!(state, json!({ "count": 0, "name": "test" }));
553
- }
554
-
555
- #[test]
556
- fn test_variable_reference() {
557
- let mut analyzer = JsAnalyzer::new(PathBuf::from("."));
558
- let script = r#"
559
- const items = [1, 2, 3];
560
- vibe({ items });
561
- "#;
562
-
563
- let state = analyzer.extract_state(script).unwrap();
564
- assert_eq!(state, json!({ "items": [1, 2, 3] }));
565
- }
566
-
567
- #[test]
568
- fn test_spread_operator() {
569
- let mut analyzer = JsAnalyzer::new(PathBuf::from("."));
570
- let script = r#"
571
- const base = { a: 1, b: 2 };
572
- vibe({ ...base, c: 3 });
573
- "#;
574
-
575
- let state = analyzer.extract_state(script).unwrap();
576
- assert_eq!(state, json!({ "a": 1, "b": 2, "c": 3 }));
577
- }
578
-
579
- #[test]
580
- fn test_nested_objects() {
581
- let mut analyzer = JsAnalyzer::new(PathBuf::from("."));
582
- let script = r#"
583
- const user = { name: "John", age: 30 };
584
- vibe({ user, count: 0 });
585
- "#;
586
-
587
- let state = analyzer.extract_state(script).unwrap();
588
- assert_eq!(state, json!({ "user": { "name": "John", "age": 30 }, "count": 0 }));
589
- }
590
-
591
- #[test]
592
- fn test_array_spread() {
593
- let mut analyzer = JsAnalyzer::new(PathBuf::from("."));
594
- let script = r#"
595
- const base = [1, 2];
596
- const items = [...base, 3, 4];
597
- vibe({ items });
598
- "#;
599
-
600
- let state = analyzer.extract_state(script).unwrap();
601
- assert_eq!(state, json!({ "items": [1, 2, 3, 4] }));
602
- }
603
-
604
- #[test]
605
- fn test_import_resolution() {
606
- let mut analyzer = JsAnalyzer::new(PathBuf::from("/tmp"));
607
- let script = r#"
608
- import { data } from './test-import.js';
609
- vibe({ ...data, c: 3 });
610
- "#;
611
-
612
- let state = analyzer.extract_state(script).unwrap();
613
- assert_eq!(state["a"].as_f64().unwrap(), 1.0);
614
- assert_eq!(state["b"].as_f64().unwrap(), 2.0);
615
- assert_eq!(state["c"].as_f64().unwrap(), 3.0);
616
- }
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
-
675
- #[test]
676
- fn test_absolute_path_import() {
677
- let mut analyzer = JsAnalyzer::new(PathBuf::from("/tmp"));
678
- let script = r#"
679
- import { menuState } from '/test-data.js';
680
- vibe({ ...menuState, extra: "value" });
681
- "#;
682
-
683
- let state = analyzer.extract_state(script);
684
- assert!(state.is_some(), "State should be extracted");
685
- let state = state.unwrap();
686
- assert!(state["menuSections"].is_array(), "menuSections should be an array");
687
- assert_eq!(state["other"].as_str().unwrap(), "value");
688
- assert_eq!(state["extra"].as_str().unwrap(), "value");
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
- }
715
- }