@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.
- package/CHANGELOG.md +49 -0
- package/README.md +98 -1
- 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 +459 -9
- package/compiler/src/compiler/mod.rs +1 -0
- package/compiler/src/compiler/spa.rs +477 -0
- package/compiler/src/compiler/watcher.rs +182 -20
- package/compiler/src/config.rs +41 -1
- package/compiler/src/main.rs +12 -1
- package/index.js +17 -3
- package/llms.txt +29 -0
- package/package.json +2 -1
- package/runtime/component.js +145 -14
- package/runtime/hydrate.js +46 -0
- package/runtime/index.js +27 -0
- package/runtime/parse.js +25 -5
- package/runtime/pre-compiled-manifest.js +18 -1
- package/spa.js +143 -0
|
@@ -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
|
-
|
|
644
|
-
|
|
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 {
|
package/compiler/src/config.rs
CHANGED
|
@@ -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
|
|
package/compiler/src/main.rs
CHANGED
|
@@ -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
|
|
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
|
|
37
|
-
|
|
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.
|
|
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
|
},
|