@ape-egg/vibe 2.1.21 → 2.3.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.
@@ -426,6 +426,39 @@ fn exit_when_orphaned(lock_path: PathBuf) {
426
426
  #[cfg(not(unix))]
427
427
  fn exit_when_orphaned(_lock_path: PathBuf) {}
428
428
 
429
+ /// A config change invalidates every piece of watcher state — dependency
430
+ /// graph, compiler, parser, watch roots, output lock. The cleanest
431
+ /// expression is a fresh start: release the lock and exec the same binary
432
+ /// with the same argv, so CLI flag overrides re-apply with perfect parity.
433
+ /// Same pid — a parent process watching this child never sees an exit.
434
+ fn restart_with_fresh_config(lock: WatchLock) -> ! {
435
+ println!(
436
+ "{} package.json changed — restarting with fresh config",
437
+ "[watch]".cyan()
438
+ );
439
+ drop(lock);
440
+ let exe = std::env::current_exe()
441
+ .unwrap_or_else(|_| PathBuf::from(std::env::args().next().unwrap_or_default()));
442
+ let args: Vec<String> = std::env::args().skip(1).collect();
443
+ #[cfg(unix)]
444
+ {
445
+ use std::os::unix::process::CommandExt;
446
+ let err = std::process::Command::new(&exe).args(&args).exec();
447
+ eprintln!("{}: watcher restart failed: {}", "Error".red(), err);
448
+ std::process::exit(1);
449
+ }
450
+ #[cfg(not(unix))]
451
+ {
452
+ let code = std::process::Command::new(&exe)
453
+ .args(&args)
454
+ .status()
455
+ .ok()
456
+ .and_then(|status| status.code())
457
+ .unwrap_or(1);
458
+ std::process::exit(code);
459
+ }
460
+ }
461
+
429
462
  /// Start watching for file changes
430
463
  pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn std::error::Error>> {
431
464
  // Held for the watcher's whole lifetime; a second watcher on the same
@@ -544,6 +577,12 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
544
577
  // cache keys against the same base.
545
578
  let canonical_source = config.source.canonicalize()
546
579
  .unwrap_or_else(|_| config.source.clone());
580
+ // Canonical pages root: notify events carry absolute paths, so a relative
581
+ // --cwd would never prefix-match without canonicalizing the base.
582
+ let canonical_pages = {
583
+ let pages = config.source.join(&config.pages);
584
+ pages.canonicalize().unwrap_or(pages)
585
+ };
547
586
 
548
587
  // Keep compiler and parser alive to reuse component cache across incremental compilations
549
588
  let mut watch_compiler = Compiler::new(config.clone(), false);
@@ -565,6 +604,19 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
565
604
 
566
605
  debouncer.watcher().watch(&config.source, RecursiveMode::Recursive)?;
567
606
 
607
+ // Hot config: package.json is the compiler's config file — a change to
608
+ // any vibe-compiler option (spa, minify, paths) takes effect live via a
609
+ // watcher self-restart. Watched explicitly: it can sit outside the source
610
+ // tree, and skip_files (which routinely lists package.json for copying)
611
+ // must not silence it. The raw baseline is compared on each event so
612
+ // formatting-only writes don't restart anything.
613
+ let config_file = config.working_dir.join("package.json");
614
+ let canonical_config_file = config_file.canonicalize().unwrap_or_else(|_| config_file.clone());
615
+ let config_baseline = Config::load(config.working_dir.clone());
616
+ if config_file.exists() {
617
+ debouncer.watcher().watch(&config_file, RecursiveMode::NonRecursive)?;
618
+ }
619
+
568
620
  loop {
569
621
  match rx.recv() {
570
622
  Ok(result) => {
@@ -575,6 +627,20 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
575
627
 
576
628
  for event in events {
577
629
  for path in &event.paths {
630
+ // Config file first, before any filter: a changed
631
+ // vibe-compiler section restarts the watcher in
632
+ // place with the fresh config.
633
+ let is_config_file = path
634
+ .canonicalize()
635
+ .map(|p| p == canonical_config_file)
636
+ .unwrap_or(*path == config_file);
637
+ if is_config_file {
638
+ if Config::load(config.working_dir.clone()) != config_baseline {
639
+ restart_with_fresh_config(_watch_lock);
640
+ }
641
+ continue;
642
+ }
643
+
578
644
  // Skip blacklisted files/directories (check entire path, not just filename)
579
645
  if is_path_blacklisted(path, &config.source, &config.skip_files) {
580
646
  continue;
@@ -598,6 +664,16 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
598
664
  // component plus the ancestors that inline it. Everything
599
665
  // else stays cached and is reused.
600
666
  let mut stale_component_keys: HashSet<String> = HashSet::new();
667
+ // Changed component files to re-mirror into the output —
668
+ // runtime-fetched components are served from that mirror,
669
+ // so it must track every edit/deletion even when no page
670
+ // inlines the component (zero graph dependents).
671
+ let mut components_to_mirror: Vec<PathBuf> = Vec::new();
672
+ // SPA mode: any pages-tree change (edit, add, delete) or a
673
+ // component edit with page dependents re-runs the whole SPA
674
+ // pass after this batch — fragments re-transform, the shell
675
+ // recomposes, routes resync, orphans prune.
676
+ let mut spa_pages_changed = false;
601
677
 
602
678
  for path in &changed_paths {
603
679
  // Skip files in output directory (avoid infinite loop)
@@ -614,12 +690,39 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
614
690
  let relative_path = path.strip_prefix(&config.source)
615
691
  .unwrap_or(path);
616
692
 
693
+ // SPA pages never take the MPA per-page path — the
694
+ // batch-level SPA pass owns them. Deleted files can't
695
+ // canonicalize; their event path is already absolute.
696
+ if config.spa
697
+ && path.extension().and_then(|e| e.to_str()) == Some("html")
698
+ && canonical_path.starts_with(&canonical_pages)
699
+ {
700
+ if path.exists() {
701
+ println!("{} {} changed", "[watch]".cyan(), relative_path.display());
702
+ } else {
703
+ println!("{} {} deleted", "[watch]".yellow(), relative_path.display());
704
+ }
705
+ spa_pages_changed = true;
706
+ continue;
707
+ }
708
+
617
709
  // Check if it's a component
618
710
  if path.starts_with(&config.source.join(&config.components)) {
619
711
  // Component changed - recompile all transitively dependent pages
620
712
  // Canonicalize to match how dependencies were stored (handles case sensitivity)
621
713
  let path_canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
622
714
 
715
+ // The output's components mirror tracks every change,
716
+ // dependents or not: a component nothing inlines is
717
+ // still fetched from the mirror at runtime
718
+ // (`<component src="@[page.src]">`, iter-prop roots).
719
+ if path.exists() {
720
+ println!("{} {} changed", "[watch]".cyan(), relative_path.display());
721
+ } else {
722
+ println!("{} {} deleted", "[watch]".yellow(), relative_path.display());
723
+ }
724
+ components_to_mirror.push(path.clone());
725
+
623
726
  // Refresh this component's own dependency edges so a
624
727
  // newly-added <component src> (e.g. a child file created
625
728
  // mid-session) is learned. Without this the new child maps
@@ -639,15 +742,29 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
639
742
  }
640
743
  }
641
744
 
642
- let dependent_pages = graph.get_all_dependent_pages(&path_canonical);
643
- if !dependent_pages.is_empty() {
644
- println!("{} {} changed", "[watch]".cyan(), relative_path.display());
645
-
745
+ let mut dependent_pages = graph.get_all_dependent_pages(&path_canonical);
746
+ // SPA pages re-transform through the SPA pass, not
747
+ // the MPA per-page compile (graph paths are
748
+ // canonical — compare against the canonical root).
749
+ let mut has_spa_dependents = false;
750
+ if config.spa {
751
+ let (spa_pages, mpa_pages): (HashSet<_>, HashSet<_>) = dependent_pages
752
+ .into_iter()
753
+ .partition(|page| page.starts_with(&canonical_pages));
754
+ if !spa_pages.is_empty() {
755
+ spa_pages_changed = true;
756
+ has_spa_dependents = true;
757
+ }
758
+ dependent_pages = mpa_pages;
759
+ }
760
+ if !dependent_pages.is_empty() || has_spa_dependents {
646
761
  // Invalidate only the edited component and the
647
762
  // components whose cached inlined content embeds it
648
763
  // (its ancestors). Every other component stays cached,
649
764
  // so each affected page re-expands just the changed
650
- // subtree instead of its whole component tree.
765
+ // subtree instead of its whole component tree. The
766
+ // SPA pass re-expands through the same cache, so
767
+ // spa-dependent pages need this too.
651
768
  for stale in graph.get_all_dependent_components(&path_canonical) {
652
769
  if let Some(key) = component_cache_key(&stale, &canonical_source) {
653
770
  stale_component_keys.insert(key);
@@ -762,7 +879,7 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
762
879
  }
763
880
  }
764
881
 
765
- if html_files.is_empty() && asset_files.is_empty() {
882
+ if html_files.is_empty() && asset_files.is_empty() && components_to_mirror.is_empty() && !spa_pages_changed {
766
883
  continue;
767
884
  }
768
885
 
@@ -789,22 +906,24 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
789
906
  let mut manifest_stats_result = None;
790
907
  let mut had_errors = false;
791
908
 
909
+ // Drop only the stale component caches (edited components +
910
+ // their inlining ancestors); unchanged components are reused.
911
+ // A page-only edit invalidates nothing here — its components
912
+ // are still valid — so the whole cache is reused as-is. Runs
913
+ // before BOTH the per-page compile and the SPA pass: either
914
+ // may re-expand through this cache.
915
+ let invalidated = watch_compiler.invalidate_components(&stale_component_keys);
916
+ if invalidated > 0 {
917
+ println!("{} {} component{} re-expanded, {} reused from cache",
918
+ "↻".cyan(),
919
+ invalidated,
920
+ if invalidated == 1 { "" } else { "s" },
921
+ watch_compiler.cached_component_count(),
922
+ );
923
+ }
924
+
792
925
  // Compile HTML files
793
926
  if !html_files.is_empty() {
794
- // Drop only the stale component caches (edited components +
795
- // their inlining ancestors); unchanged components are reused.
796
- // A page-only edit invalidates nothing here — its components
797
- // are still valid — so the whole cache is reused as-is.
798
- let invalidated = watch_compiler.invalidate_components(&stale_component_keys);
799
- if invalidated > 0 {
800
- println!("{} {} component{} re-expanded, {} reused from cache",
801
- "↻".cyan(),
802
- invalidated,
803
- if invalidated == 1 { "" } else { "s" },
804
- watch_compiler.cached_component_count(),
805
- );
806
- }
807
-
808
927
  match watch_compiler.compile_specific_html_files(&html_files, &parser) {
809
928
  Ok(stats) => {
810
929
  total_stats.files_compiled = stats.files_compiled;
@@ -851,6 +970,49 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
851
970
  }
852
971
  }
853
972
 
973
+ // Keep the output's components mirror in sync — changed
974
+ // components reach it even with zero dependent pages, and
975
+ // deleted ones leave it (see mirror_component_files).
976
+ if !components_to_mirror.is_empty() && !had_errors {
977
+ match watch_compiler.mirror_component_files(&components_to_mirror) {
978
+ Ok(copied) => {
979
+ total_stats.files_copied += copied;
980
+ }
981
+ Err(e) => {
982
+ eprintln!("{}: {}", "Error".red(), e);
983
+ eprintln!("Fix the errors and save to retry.");
984
+ println!();
985
+ had_errors = true;
986
+ }
987
+ }
988
+ }
989
+
990
+ // SPA pass: re-transform fragments, recompose the shell,
991
+ // resync the route table, prune orphans, refresh the
992
+ // shell's manifest.
993
+ if spa_pages_changed && !had_errors {
994
+ match watch_compiler.compile_spa(&parser) {
995
+ Ok(count) => {
996
+ if !config.runtime_as_is {
997
+ if let Err(e) = watch_compiler.generate_shell_manifest() {
998
+ eprintln!("{}: Shell manifest failed: {}", "Warning".yellow(), e);
999
+ }
1000
+ }
1001
+ println!("{} Recompiled SPA ({} fragment{} + shell)",
1002
+ "✓".green(),
1003
+ count - 1,
1004
+ if count == 2 { "" } else { "s" }
1005
+ );
1006
+ }
1007
+ Err(e) => {
1008
+ eprintln!("{}: {}", "Error".red(), e);
1009
+ eprintln!("Fix the errors and save to retry.");
1010
+ println!();
1011
+ had_errors = true;
1012
+ }
1013
+ }
1014
+ }
1015
+
854
1016
  if !had_errors {
855
1017
  // Show what was updated
856
1018
  if total_stats.files_compiled > 0 {
@@ -48,6 +48,19 @@ pub struct VibeCompilerConfig {
48
48
  pub no_clean: bool,
49
49
  #[serde(default)]
50
50
  pub fouc_as_is: bool,
51
+ #[serde(default, deserialize_with = "deserialize_spa")]
52
+ pub spa: bool,
53
+ }
54
+
55
+ // "spa": true is a boolean today; the future per-page selection form is an
56
+ // object. Parse tolerantly so a newer config never silently resets the whole
57
+ // vibe-compiler section to defaults on an older compiler.
58
+ fn deserialize_spa<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<bool, D::Error> {
59
+ Ok(match serde_json::Value::deserialize(deserializer)? {
60
+ serde_json::Value::Bool(enabled) => enabled,
61
+ serde_json::Value::Object(_) => true,
62
+ _ => false,
63
+ })
51
64
  }
52
65
 
53
66
  fn default_source() -> String { "./".to_string() }
@@ -105,11 +118,12 @@ impl Default for VibeCompilerConfig {
105
118
  iterations_as_is: false,
106
119
  no_clean: false,
107
120
  fouc_as_is: false,
121
+ spa: false,
108
122
  }
109
123
  }
110
124
  }
111
125
 
112
- #[derive(Debug, Clone)]
126
+ #[derive(Debug, Clone, PartialEq)]
113
127
  pub struct Config {
114
128
  pub source: PathBuf,
115
129
  pub output: PathBuf,
@@ -130,12 +144,19 @@ pub struct Config {
130
144
  pub iterations_as_is: bool,
131
145
  pub no_clean: bool,
132
146
  pub fouc_as_is: bool,
147
+ pub spa: bool,
133
148
  pub working_dir: PathBuf,
134
149
  }
135
150
 
136
151
  impl Config {
137
152
  /// Load config from package.json, or use defaults if not found
138
153
  pub fn load(working_dir: PathBuf) -> Self {
154
+ // A relative --cwd must become absolute HERE: every derived path
155
+ // (source, output, pages) joins from working_dir, and downstream
156
+ // strip_prefix against filesystem-event paths (always absolute)
157
+ // otherwise fails — in watch mode that once routed a manifest write
158
+ // and a stamped page INTO the source tree.
159
+ let working_dir = working_dir.canonicalize().unwrap_or(working_dir);
139
160
  let package_path = working_dir.join("package.json");
140
161
 
141
162
  let config = if package_path.exists() {
@@ -185,6 +206,7 @@ impl Config {
185
206
  iterations_as_is: config.iterations_as_is,
186
207
  no_clean: config.no_clean,
187
208
  fouc_as_is: config.fouc_as_is,
209
+ spa: config.spa,
188
210
  working_dir,
189
211
  }
190
212
  }
@@ -202,6 +224,24 @@ impl Config {
202
224
  }
203
225
  }
204
226
 
227
+ #[cfg(test)]
228
+ mod tests {
229
+ use super::*;
230
+
231
+ fn parse(json: &str) -> VibeCompilerConfig {
232
+ serde_json::from_str(json).unwrap()
233
+ }
234
+
235
+ #[test]
236
+ fn spa_parses_bool_and_tolerates_the_future_object_form() {
237
+ assert!(!parse(r#"{}"#).spa);
238
+ assert!(parse(r#"{"spa": true}"#).spa);
239
+ assert!(!parse(r#"{"spa": false}"#).spa);
240
+ // Future per-page selection form must not break older compilers.
241
+ assert!(parse(r#"{"spa": {"pages": ["docs"]}}"#).spa);
242
+ }
243
+ }
244
+
205
245
  pub fn init_config(working_dir: &PathBuf) -> Result<(), ConfigError> {
206
246
  let package_path = working_dir.join("package.json");
207
247
 
@@ -19,13 +19,14 @@ struct ConfigOverrides {
19
19
  components_as_is: bool,
20
20
  runtime_as_is: bool,
21
21
  iterations_as_is: bool,
22
+ spa: bool,
22
23
  }
23
24
 
24
25
  /// Vibe Compiler - Compiles Vibe source files into optimized output
25
26
  #[derive(ClapParser, Debug)]
26
27
  #[command(name = "vibe-compile")]
27
28
  #[command(author = "Kim Korte")]
28
- #[command(version = "1.6.1")]
29
+ #[command(version)]
29
30
  #[command(about = "Compiles Vibe source files into optimized output")]
30
31
  struct Args {
31
32
  /// Working directory (defaults to current directory)
@@ -87,6 +88,10 @@ struct Args {
87
88
  /// Keep FOUC prevention class/attribute in compiled output
88
89
  #[arg(long, name = "fouc-as-is")]
89
90
  fouc_as_is: bool,
91
+
92
+ /// Compile the pages tree to SPA output: fragments + route table + shell
93
+ #[arg(long)]
94
+ spa: bool,
90
95
  }
91
96
 
92
97
  fn main() {
@@ -127,6 +132,7 @@ fn main() {
127
132
  let original_components_as_is = config.components_as_is;
128
133
  let original_runtime_as_is = config.runtime_as_is;
129
134
  let original_iterations_as_is = config.iterations_as_is;
135
+ let original_spa = config.spa;
130
136
 
131
137
  // Track overrides for verbose output
132
138
  let mut overrides = ConfigOverrides::default();
@@ -165,6 +171,10 @@ fn main() {
165
171
  if args.fouc_as_is {
166
172
  config.fouc_as_is = true;
167
173
  }
174
+ if args.spa {
175
+ overrides.spa = !original_spa;
176
+ config.spa = true;
177
+ }
168
178
 
169
179
  if args.verbose {
170
180
  println!("{}", format!("Vibe Compiler v{}", env!("CARGO_PKG_VERSION")).cyan().bold());
@@ -228,6 +238,7 @@ fn main() {
228
238
  println!(" {}: {}", format!("{:<16}", "skipFiles").cyan(), format_value_no_flag(format_list_preview(&config.skip_files)));
229
239
  println!(" {}: {}", format!("{:<16}", "source").cyan(), format_value_no_flag(&config._source_str));
230
240
  println!(" {}: {}", format!("{:<16}", "sourceMaps").cyan(), format_bool_with_flag(config.source_maps, overrides.source_maps, original_source_maps));
241
+ println!(" {}: {}", format!("{:<16}", "spa").cyan(), format_bool_with_flag(config.spa, overrides.spa, original_spa));
231
242
  println!();
232
243
  } else {
233
244
  // Show version in non-verbose mode
package/index.js CHANGED
@@ -8,7 +8,7 @@ let vibeInstance = null;
8
8
  let resolveInstanceReady = null;
9
9
 
10
10
  const createVibeInstance = () => ({
11
- _pendingListeners: { afterUpdate: [], afterDomMutation: [], ready: [] },
11
+ _pendingListeners: { afterUpdate: [], afterDomMutation: [], ready: [], unmount: [] },
12
12
  // Promise that resolves when the real $.ready resolves post-boot. Lets
13
13
  // consumers holding the pre-boot placeholder (e.g. tests awaiting
14
14
  // `window.$.ready` before boot has replaced $ with the reactive proxy)
@@ -31,10 +31,24 @@ const createVibeInstance = () => ({
31
31
  }
32
32
  });
33
33
 
34
+ // Shallow, key-level defaults: set only keys `target` does not have yet.
35
+ // This is what "initial state, declared again" means once the app is live —
36
+ // a re-mounted SPA page fragment's vibe({ ... }) seeds on first mount and
37
+ // never clobbers live state after (vibe() state is app-lifetime; per-visit
38
+ // state belongs in a component()).
39
+ export const applyDefaults = (target, state) => {
40
+ for (const key in state) {
41
+ if (!(key in target)) target[key] = state[key];
42
+ }
43
+ return target;
44
+ };
45
+
34
46
  const vibe = (state = {}, config, targetSelector) => {
35
47
  if (isBooted()) {
36
- // Already booted - merge state into live proxy
37
- Object.assign(window.$, state);
48
+ // Already booted: initial state declared late seeds missing keys only —
49
+ // on a fresh document load this branch never runs, so MPA behavior is
50
+ // byte-identical.
51
+ applyDefaults(window.$, state);
38
52
  return window.$;
39
53
  }
40
54
 
package/llms.txt CHANGED
@@ -214,10 +214,39 @@ This makes vibe usable as a "sprinkle of reactivity" library: paste a snippet in
214
214
  $.on('ready', () => {}); // once, after initial parse + hydrate + components mounted
215
215
  $.on('afterUpdate', (cur, prev) => {}); // every state change (batched per microtask)
216
216
  $.on('afterDomMutation', () => {}); // after every MutationObserver batch
217
+ $.on('unmount', () => {}); // scope-resolved teardown: in a component script, fires when
218
+ // THAT component unmounts; at page level, fires on pagehide
217
219
  ```
218
220
 
219
221
  Also: `await $.ready` resolves after boot, useful when calling code captured `window.$` before vibe finished initializing.
220
222
 
223
+ ## SPA Routing (`@ape-egg/vibe/spa`)
224
+
225
+ A standalone router maintaining `$.page = { path, route, params, src }`; a reactive component src is the route outlet:
226
+
227
+ ```html
228
+ <script type="module">
229
+ import vibe from '@ape-egg/vibe';
230
+ import { setupSpa, resolve } from '@ape-egg/vibe/spa';
231
+
232
+ const routes = [
233
+ { route: '/brawlers/:index', src: '/components/brawler.html', title: 'Brawler' }, // :param = one segment
234
+ { route: '/docs/:rest*', src: '/components/docs.html' }, // trailing :name* = zero or more
235
+ { route: '/', src: '/components/home.html', title: 'Home' },
236
+ { route: '*', src: '/components/lost.html' }, // no-match fallback (deep links/popstate only)
237
+ ];
238
+
239
+ window.$ = vibe({ page: resolve(location, routes) ?? {} });
240
+ setupSpa({ routes }); // returns { navigate, dispose }
241
+ </script>
242
+
243
+ <component src="@[page.src]"></component>
244
+ ```
245
+
246
+ Rules: tables are pre-sorted most-specific-first, first match wins. Clicks are claimed only for same-origin, unmodified, untargeted links whose pathname matches a real route (`'*'` never claims) — everything else navigates natively, so mixed MPA/SPA output works. Route titles swap `document.title`. `resolve(location, routes)` is pure. A custom `onNavigate` makes it a pure router without Vibe.
247
+
248
+ State semantics under SPA: `vibe()` state is app-lifetime, `component()` state is mount-lifetime (resets per visit). `@ape-egg/vibe/defaults` is the vibe entry with defaults semantics — already booted, it sets only keys that don't exist yet on `$`, so re-running page scripts never clobber live state. The compiler's SPA mode (`"spa": true` in `vibe-compiler` config, or `--spa`) compiles an MPA `pages/` tree into page fragments under `/components/vibe-spa/`, a generated route table, and a composed `/index.html` shell wired to this router — deploy with one rewrite: every route → `/index.html`.
249
+
221
250
  ## Special Attributes
222
251
 
223
252
  ### vibe-fouc
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "2.1.21",
3
+ "version": "2.3.0",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",
@@ -10,6 +10,7 @@
10
10
  "./boot": "./boot.js",
11
11
  "./component": "./component.js",
12
12
  "./hot-module-refresh": "./hot-module-refresh.js",
13
+ "./spa": "./spa.js",
13
14
  "./runtime": "./runtime/index.js",
14
15
  "./compiler": "./compiler/bin/vibe-compile.js"
15
16
  },