@ape-egg/vibe 2.1.22 → 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 +37 -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 +367 -9
- package/compiler/src/compiler/mod.rs +1 -0
- package/compiler/src/compiler/spa.rs +477 -0
- package/compiler/src/compiler/watcher.rs +149 -18
- package/compiler/src/config.rs +41 -1
- package/compiler/src/main.rs +11 -0
- package/index.js +16 -2
- package/llms.txt +29 -0
- package/package.json +2 -1
- package/runtime/component.js +53 -4
- package/runtime/hydrate.js +30 -3
- package/runtime/index.js +15 -0
- package/runtime/parse.js +12 -6
- 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;
|
|
@@ -603,6 +669,11 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
|
|
|
603
669
|
// so it must track every edit/deletion even when no page
|
|
604
670
|
// inlines the component (zero graph dependents).
|
|
605
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;
|
|
606
677
|
|
|
607
678
|
for path in &changed_paths {
|
|
608
679
|
// Skip files in output directory (avoid infinite loop)
|
|
@@ -619,6 +690,22 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
|
|
|
619
690
|
let relative_path = path.strip_prefix(&config.source)
|
|
620
691
|
.unwrap_or(path);
|
|
621
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
|
+
|
|
622
709
|
// Check if it's a component
|
|
623
710
|
if path.starts_with(&config.source.join(&config.components)) {
|
|
624
711
|
// Component changed - recompile all transitively dependent pages
|
|
@@ -655,13 +742,29 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
|
|
|
655
742
|
}
|
|
656
743
|
}
|
|
657
744
|
|
|
658
|
-
let dependent_pages = graph.get_all_dependent_pages(&path_canonical);
|
|
659
|
-
|
|
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 {
|
|
660
761
|
// Invalidate only the edited component and the
|
|
661
762
|
// components whose cached inlined content embeds it
|
|
662
763
|
// (its ancestors). Every other component stays cached,
|
|
663
764
|
// so each affected page re-expands just the changed
|
|
664
|
-
// 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.
|
|
665
768
|
for stale in graph.get_all_dependent_components(&path_canonical) {
|
|
666
769
|
if let Some(key) = component_cache_key(&stale, &canonical_source) {
|
|
667
770
|
stale_component_keys.insert(key);
|
|
@@ -776,7 +879,7 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
|
|
|
776
879
|
}
|
|
777
880
|
}
|
|
778
881
|
|
|
779
|
-
if html_files.is_empty() && asset_files.is_empty() && components_to_mirror.is_empty() {
|
|
882
|
+
if html_files.is_empty() && asset_files.is_empty() && components_to_mirror.is_empty() && !spa_pages_changed {
|
|
780
883
|
continue;
|
|
781
884
|
}
|
|
782
885
|
|
|
@@ -803,22 +906,24 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
|
|
|
803
906
|
let mut manifest_stats_result = None;
|
|
804
907
|
let mut had_errors = false;
|
|
805
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
|
+
|
|
806
925
|
// Compile HTML files
|
|
807
926
|
if !html_files.is_empty() {
|
|
808
|
-
// Drop only the stale component caches (edited components +
|
|
809
|
-
// their inlining ancestors); unchanged components are reused.
|
|
810
|
-
// A page-only edit invalidates nothing here — its components
|
|
811
|
-
// are still valid — so the whole cache is reused as-is.
|
|
812
|
-
let invalidated = watch_compiler.invalidate_components(&stale_component_keys);
|
|
813
|
-
if invalidated > 0 {
|
|
814
|
-
println!("{} {} component{} re-expanded, {} reused from cache",
|
|
815
|
-
"↻".cyan(),
|
|
816
|
-
invalidated,
|
|
817
|
-
if invalidated == 1 { "" } else { "s" },
|
|
818
|
-
watch_compiler.cached_component_count(),
|
|
819
|
-
);
|
|
820
|
-
}
|
|
821
|
-
|
|
822
927
|
match watch_compiler.compile_specific_html_files(&html_files, &parser) {
|
|
823
928
|
Ok(stats) => {
|
|
824
929
|
total_stats.files_compiled = stats.files_compiled;
|
|
@@ -882,6 +987,32 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
|
|
|
882
987
|
}
|
|
883
988
|
}
|
|
884
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
|
+
|
|
885
1016
|
if !had_errors {
|
|
886
1017
|
// Show what was updated
|
|
887
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,6 +19,7 @@ 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
|
|
@@ -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
|
@@ -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
|
},
|
package/runtime/component.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { debugLog } from './debug.js';
|
|
2
|
+
import { shouldCleanup } from './cleanup.js';
|
|
2
3
|
import {
|
|
3
4
|
PHASE_FETCH,
|
|
4
5
|
PHASE_FETCH_CACHED,
|
|
@@ -305,6 +306,7 @@ export const liveComponentWrapper = (element) => {
|
|
|
305
306
|
// the removal pass when replaceWith drops the old wrapper.
|
|
306
307
|
export const remountComponent = (el, src, debug = false) => {
|
|
307
308
|
const wasFetching = pendingFetches.has(el);
|
|
309
|
+
const hadSrcAttr = el.hasAttribute('src');
|
|
308
310
|
// Compare against the LATEST requested src: with a fetch in flight the src
|
|
309
311
|
// attribute holds it (rapid navigation A→B→A must abort B, not no-op on A);
|
|
310
312
|
// mounted and idle, the finalize-stashed value does.
|
|
@@ -314,9 +316,23 @@ export const remountComponent = (el, src, debug = false) => {
|
|
|
314
316
|
if (src === current) return;
|
|
315
317
|
abortComponentFetch(el);
|
|
316
318
|
el.setAttribute('src', src);
|
|
317
|
-
// Pre-fetch element
|
|
318
|
-
//
|
|
319
|
-
|
|
319
|
+
// Pre-fetch element awaiting its initial processing pass: that pass reads
|
|
320
|
+
// the new value — nothing to redo. A declaration-form wrapper (bound src
|
|
321
|
+
// that resolved to nothing, carried on data-vibe-src with no src
|
|
322
|
+
// attribute) was invisible to that pass, so hydration owns its first
|
|
323
|
+
// fetch too — the observer doesn't watch attributes.
|
|
324
|
+
if (el._vibeMountedSrc === undefined && !wasFetching && hadSrcAttr) return;
|
|
325
|
+
processSingle(el, debug);
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
// Key-change remount (`key="@[page.path]"`): mount the CURRENT src fresh even
|
|
329
|
+
// though it is unchanged. Only a mounted, idle wrapper has anything to redo —
|
|
330
|
+
// with a fetch pending the incoming mount is already fresh (a src change in
|
|
331
|
+
// the same flush started it), and an unmounted wrapper's first mount is owned
|
|
332
|
+
// by the normal processing pass.
|
|
333
|
+
export const forceRemount = (el, debug = false) => {
|
|
334
|
+
if (pendingFetches.has(el) || el._vibeMountedSrc === undefined) return;
|
|
335
|
+
el.setAttribute('src', el._vibeMountedSrc);
|
|
320
336
|
processSingle(el, debug);
|
|
321
337
|
};
|
|
322
338
|
|
|
@@ -542,8 +558,11 @@ const processSingle = (el, debug) => {
|
|
|
542
558
|
let props = el._vibeRemountProps;
|
|
543
559
|
if (!props) {
|
|
544
560
|
props = {};
|
|
561
|
+
// `src` and `key` are the wrapper's own contract (what to mount / when to
|
|
562
|
+
// remount), and data-vibe-* attributes are runtime transport — none of
|
|
563
|
+
// them are authored props for the component.
|
|
545
564
|
Array.from(el.attributes).forEach((attr) => {
|
|
546
|
-
if (attr.name !== 'src') {
|
|
565
|
+
if (attr.name !== 'src' && attr.name !== 'key' && !attr.name.startsWith('data-vibe-')) {
|
|
547
566
|
props[attr.name] = attr.value;
|
|
548
567
|
}
|
|
549
568
|
});
|
|
@@ -697,6 +716,12 @@ const processSingle = (el, debug) => {
|
|
|
697
716
|
// attribute — the DOM alone carries the knowledge across swaps.
|
|
698
717
|
const srcBinding = el._vibeSrcBinding ?? el.getAttribute('data-vibe-src');
|
|
699
718
|
if (srcBinding) newWrapper.setAttribute('data-vibe-src', srcBinding);
|
|
719
|
+
// The key binding and its last resolved value ride along the same
|
|
720
|
+
// way, so a later key change still finds what to compare against
|
|
721
|
+
// on the replacement wrapper.
|
|
722
|
+
const keyBinding = el._vibeKeyBinding ?? el.getAttribute('data-vibe-key');
|
|
723
|
+
if (keyBinding) newWrapper.setAttribute('data-vibe-key', keyBinding);
|
|
724
|
+
if (el._vibeMountedKey !== undefined) newWrapper._vibeMountedKey = el._vibeMountedKey;
|
|
700
725
|
// Transfer iteration-prop registry ownership from the soon-to-be-
|
|
701
726
|
// detached `<component src>` to the new wrapper. The detached element
|
|
702
727
|
// would otherwise trigger releaseOrphanedIterationProps and free the
|
|
@@ -732,13 +757,37 @@ const processSingle = (el, debug) => {
|
|
|
732
757
|
// renderAllConditionals/Iterations populated runtime data) is what
|
|
733
758
|
// iterate.js's update path uses to re-evaluate inlined bindings on
|
|
734
759
|
// each row update.
|
|
760
|
+
// The observer hydrates the inserted subtree in its NEXT batch —
|
|
761
|
+
// until then, selectors keyed on hydrated attributes (a name-bound
|
|
762
|
+
// <page @[page.name]> → page[pvp] rules) don't match and the
|
|
763
|
+
// content paints unstyled. Cover the gap with the same fouc
|
|
764
|
+
// contract pages use: hidden at insertion, revealed by the batch
|
|
765
|
+
// that parsed and hydrated this subtree.
|
|
766
|
+
newWrapper.setAttribute('vibe-fouc', '');
|
|
735
767
|
el._vibeReplacedBy = newWrapper;
|
|
736
768
|
el.replaceWith(newWrapper);
|
|
737
769
|
debugLog(fromCache ? PHASE_FETCH_CACHED : PHASE_FETCH, src, debug);
|
|
738
770
|
|
|
771
|
+
// Build-inlined child components (compiled SPA fragments) arrive
|
|
772
|
+
// with tagged wrapper ids and vibe-module scripts — the compiled-
|
|
773
|
+
// document form. A fetched mount is the fourth delivery mode after
|
|
774
|
+
// boot, conditional branches, and iteration rows: run those
|
|
775
|
+
// scripts now so each child's component({...}) state registers
|
|
776
|
+
// under its build-tagged id and the _cN bindings hydrate.
|
|
777
|
+
executeCompiledComponentScriptsIn([newWrapper]);
|
|
778
|
+
|
|
739
779
|
// MutationObserver handles parsing and hydrating the new content.
|
|
740
780
|
// Branch nodes are registered in the manifest by mountBranch,
|
|
741
781
|
// so the observer can find parents even inside conditional branches.
|
|
782
|
+
// Hydration can span multiple batches (nested fetched components,
|
|
783
|
+
// async scripts) with paints in between — reveal only when the
|
|
784
|
+
// subtree has settled (same predicate the page-level ready uses).
|
|
785
|
+
// A wrapper unmounted mid-hydration releases the hook.
|
|
786
|
+
const unfouc = window.$.on('afterDomMutation', () => {
|
|
787
|
+
if (newWrapper.isConnected && !shouldCleanup(newWrapper)) return;
|
|
788
|
+
newWrapper.removeAttribute('vibe-fouc');
|
|
789
|
+
unfouc();
|
|
790
|
+
});
|
|
742
791
|
} else {
|
|
743
792
|
// Element was detached before finalize ran (conditional unmounted
|
|
744
793
|
// during fetch, parent removed, etc). Release any state component()
|
package/runtime/hydrate.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { updateIteration } from './iterate.js';
|
|
2
2
|
import { updateConditional, managedNodes } from './conditionals.js';
|
|
3
|
-
import { isComponentWrapper, liveComponentWrapper, remountComponent } from './component.js';
|
|
3
|
+
import { isComponentWrapper, liveComponentWrapper, remountComponent, forceRemount } from './component.js';
|
|
4
4
|
import { VALUE_ATTRS, DOM_PROPERTIES, BINDING_REGEX, PURE_BINDING_REGEX } from './constants.js';
|
|
5
5
|
import { evalInScope, resolveCaseInsensitivePath } from './utils.js';
|
|
6
6
|
import { RawHtml } from './raw-html.js';
|
|
@@ -91,13 +91,40 @@ export default (affected, state, manifest = {}, oldState = {}) => {
|
|
|
91
91
|
// reparse rebuilds the knowledge from that attribute. A state change
|
|
92
92
|
// landing inside the swap window still resolves through the
|
|
93
93
|
// replacement chain.
|
|
94
|
+
// Keyed component (`<component src="@[page.src]" key="@[page.path]">`):
|
|
95
|
+
// a key change is a declared identity change — remount the mounted
|
|
96
|
+
// component even when the src is unchanged (param→param navigation on
|
|
97
|
+
// the same route). The first resolution just records the initial key;
|
|
98
|
+
// the mount itself is owned by the normal component pass.
|
|
99
|
+
if (attrName === 'key' && isComponentWrapper(element)) {
|
|
100
|
+
const live = liveComponentWrapper(element);
|
|
101
|
+
const newKey = attrValue.replace(BINDING_REGEX, (_, expr) =>
|
|
102
|
+
evalInScope(expr, effectiveState, live) ?? '',
|
|
103
|
+
);
|
|
104
|
+
live._vibeKeyBinding = attrValue;
|
|
105
|
+
const prevKey = live._vibeMountedKey;
|
|
106
|
+
live._vibeMountedKey = newKey;
|
|
107
|
+
if (prevKey !== undefined && newKey !== prevKey) forceRemount(live);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
94
111
|
if (attrName === 'src' && isComponentWrapper(element)) {
|
|
95
112
|
const live = liveComponentWrapper(element);
|
|
96
113
|
const newSrc = attrValue.replace(BINDING_REGEX, (_, expr) =>
|
|
97
|
-
evalInScope(expr, effectiveState, live),
|
|
114
|
+
evalInScope(expr, effectiveState, live) ?? '',
|
|
98
115
|
);
|
|
99
116
|
live._vibeSrcBinding = attrValue;
|
|
100
|
-
|
|
117
|
+
if (newSrc) {
|
|
118
|
+
remountComponent(live, newSrc);
|
|
119
|
+
} else if (live.hasAttribute('src')) {
|
|
120
|
+
// Unresolved src mounts nothing (a no-match deep link leaves
|
|
121
|
+
// $.page.src unset): move the binding onto data-vibe-src — the
|
|
122
|
+
// established transport parse.js already reads — and drop the
|
|
123
|
+
// fetchable src, so component processing and cleanup treat the
|
|
124
|
+
// outlet as settled instead of fetching a stringified binding.
|
|
125
|
+
live.setAttribute('data-vibe-src', attrValue);
|
|
126
|
+
live.removeAttribute('src');
|
|
127
|
+
}
|
|
101
128
|
return;
|
|
102
129
|
}
|
|
103
130
|
|
package/runtime/index.js
CHANGED
|
@@ -571,6 +571,12 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
571
571
|
unmount: [],
|
|
572
572
|
};
|
|
573
573
|
|
|
574
|
+
// The ready phase happens once. A listener registered after it fires
|
|
575
|
+
// immediately (parity with the late-safe $.ready promise) — late
|
|
576
|
+
// registration is the SPA norm, where fragment scripts run on mount,
|
|
577
|
+
// long after the shell booted.
|
|
578
|
+
let readyFired = false;
|
|
579
|
+
|
|
574
580
|
// Page-scope 'unmount': the visitor actually leaving — pagehide (navigation
|
|
575
581
|
// away, tab close). Deliberately NOT visibilitychange: a tab switch is not
|
|
576
582
|
// an unmount, the visitor comes back. Inside a component script the same
|
|
@@ -689,6 +695,14 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
689
695
|
// snapshots or Object.keys($).
|
|
690
696
|
Object.defineProperty($, 'on', {
|
|
691
697
|
value: (event, callback) => {
|
|
698
|
+
if (event === 'ready' && readyFired) {
|
|
699
|
+
try {
|
|
700
|
+
callback();
|
|
701
|
+
} catch (error) {
|
|
702
|
+
console.error('[vibe] Error in ready hook:', error);
|
|
703
|
+
}
|
|
704
|
+
return () => {};
|
|
705
|
+
}
|
|
692
706
|
if (hooks[event]) {
|
|
693
707
|
hooks[event].push(callback);
|
|
694
708
|
}
|
|
@@ -1233,6 +1247,7 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
1233
1247
|
cleanupExecuted = true;
|
|
1234
1248
|
|
|
1235
1249
|
// Fire ready hook after cleanup completes
|
|
1250
|
+
readyFired = true;
|
|
1236
1251
|
hooks.ready.forEach((callback) => {
|
|
1237
1252
|
try {
|
|
1238
1253
|
callback();
|