@ape-egg/vibe 2.0.5 → 2.1.3

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 CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.1.3] - 2026-06-18
4
+
5
+ ### Added
6
+
7
+ - **Compiler 1.8.2 → 1.9.0 — `skipFiles` config option** (`compiler/src/config.rs`, `compiler/src/compiler/compile.rs`, `compiler/src/compiler/watcher.rs`, `compiler/src/main.rs`) — projects can now add their own exclude patterns on top of the compiler's built-in skip list (test files, `*.config.js`, `node_modules`, dotfiles, build dirs). Like `reservedElements`, user values **append** to the built-ins rather than replacing them, so the sensible defaults always hold. `Config::load` merges the built-in `SKIP_FILES` const with the user array into one effective `config.skip_files`, which is now threaded into `should_skip_path` at every call site (compile pass + watch mode) instead of the function reading a hard-coded const. Matching: a bare name (`server`) matches any file or directory with that name; a pattern containing `*` is a glob (`**/*.bak`) matched against both filename and full path; dotfiles are always skipped. This removes the need for app-side post-build pruning of directories the deploy never serves (a backend `server/`, build `scripts/`, a stale `dist/`). Config-only (no CLI flag, like `reservedElements`); surfaced in `--verbose` output. Repro: `tests/compiler/skip-files`.
8
+ - Docs: the compiler **Configuration** page documents `skipFiles` (example config + full section) and adds two collapsible panels revealing the built-in `reservedElements` and `skipFiles` default lists.
9
+
10
+ ## [2.1.2] - 2026-06-18
11
+
12
+ ### Fixed
13
+
14
+ - **Compiler 1.8.0 → 1.8.1 — `--minify` mangled tags whose attributes span multiple lines** (`compiler/src/compiler/compile.rs`) — `minify_html` joined trimmed source lines with no separator, so a newline *inside* a tag vanished instead of collapsing to a space. A multi-line `<meta name="viewport" content="...">` became `<metaname="viewport"content="...">`, which the downstream stamping stage then re-parsed into deeper garbage (`initial-scale="1.0,"`, `&quot;`, a bogus `</metaname...>` close). The inter-line break is now collapsed to a single space like any other whitespace run; the existing `>\s+<` → `><` pass re-tightens genuine tag boundaries. Generic fix — applies to any multi-line tag or text node, not just `<meta>`. Repro: `tests/compiler/minify-meta`.
15
+
16
+ ## [2.1.1] - 2026-06-18
17
+
18
+ ### Added
19
+
20
+ - **`[Fet(ca)ched]` debug event for cache hits** (`runtime/debug.js`, `runtime/constants.js`, `runtime/component.js`, `runtime/component-cache.js`) — in debug mode, a `<component src>` served from the runtime template cache now logs `[Fet(ca)ched]` instead of `[Fetched]`, so a real network fetch and a cache hit are visually distinct at a glance (both share the same purple). `component-cache.js` exposes `isComponentCached(src)`, captured before the fetch so the debug layer can tell the two apart. The phase-bracket padding widened 13 → 14 to keep `[Fet(ca)ched]` column-aligned with the other events.
21
+
22
+ ## [2.1.0] - 2026-06-17
23
+
24
+ ### Added
25
+
26
+ - **Component template cache** (`runtime/component-cache.js`, new) — Vibe now caches each fetched `<component src>` template by `src` instead of refetching it per instance. A page that mounts the same component many times, or an SPA that re-mounts components on navigation, previously issued one network request per instance; it now issues **one per unique template**. Two mechanisms in one small module:
27
+ - **In-flight coalescing** — the fetch *promise* is cached synchronously before its first `await`, so a burst of same-tick mounts of the same `src` shares a single request instead of stampeding the network. This is something a browser HTTP cache structurally cannot do (a cold cache can't dedupe concurrent requests for the same URL).
28
+ - **Session-lived reuse** — later mounts, including after SPA navigation, resolve from memory with no network request at all.
29
+
30
+ The cache is content-busted, never time-based: in production component templates are immutable for the life of the page, so there is nothing to invalidate and no staleness window. Per-instance props, slots, and component-local state are unaffected — only the template text is shared; each instance hydrates independently. Verified on a real page: a view that fetched 253 component files (41 unique) now fetches 41, with zero duplicate requests.
31
+ - New config flag `noCache: true` (`vibe(state, { noCache })`) disables it entirely.
32
+ - New public method `$.clearComponentCache(path?)` invalidates one entry (query string ignored) or all. `@ape-egg/vite-plugin-vibe` calls it on hot update so edits are reflected for freshly-mounted instances; the runtime stays free of dev-server coupling.
33
+ - Unit coverage: `tests/unit/component-cache.test.js` (coalescing, caching, eviction, `noCache`, non-ok/rejected responses not persisted).
34
+
3
35
  ## [2.0.5] - 2026-06-16
4
36
 
5
37
  ### Fixed
package/README.md CHANGED
@@ -45,6 +45,7 @@ window.$ = vibe(state, config?, targetSelector?);
45
45
 
46
46
  - **`config`** *(object, optional)* — runtime configuration. Currently supported keys:
47
47
  - `debug` *(boolean, default `false`)* — colored console logs for every lifecycle phase (parse, hydrate, iterate, mutate, …). Useful for debugging reactivity issues.
48
+ - `noCache` *(boolean, default `false`)* — disable the component template cache (see **Component Template Caching** below). When set, every `<component src>` mount refetches its template.
48
49
 
49
50
  - **`targetSelector`** *(string, optional)* — CSS selector for the root element vibe attaches to. **Defaults to `document.body`.** Vibe parses, hydrates, and observes mutations only inside this root — anything outside (e.g. `<head>`, sibling `<aside>` elements) is ignored. If the selector matches nothing, vibe silently falls back to `document.body`. Pass `'html'` to include `<head>` (e.g. for binding `<title>@[pageTitle]</title>`).
50
51
 
@@ -216,6 +217,26 @@ How it works:
216
217
 
217
218
  Multiple drop-in blocks on the same page each get their own state bucket. They can read each other's state via global `$['_c0'].count` if they need to coordinate, but in most drop-in cases they're independent.
218
219
 
220
+ ### Component Template Caching
221
+
222
+ Vibe loads a `<component src="...">` by fetching its HTML template. A page often mounts the same component many times (a list of cards, a row of stat bars), and an SPA re-mounts components on every navigation. By default Vibe caches each fetched template by `src`, so:
223
+
224
+ - **Concurrent mounts coalesce.** Twenty `<component src="/components/Bar.html">` in the same render share **one** in-flight request instead of stampeding the network with twenty.
225
+ - **Repeat mounts are free.** Later mounts — including after an SPA navigation away and back — resolve the template from memory with **no network request at all**. This is the one win a browser HTTP cache can't give you: it revalidates per request and never coalesces concurrent ones.
226
+
227
+ The cache is **session-lived and content-busted, never time-based**. In production a component template is immutable for the life of the page (it only changes on redeploy, which is a new session), so there is nothing to invalidate and no staleness window. Per-instance props, slots, and component-local state are unaffected — only the fetched template text is shared; each instance still hydrates independently.
228
+
229
+ Turn it off with `vibe(state, { noCache: true })` — useful if you serve component HTML that genuinely changes within a session.
230
+
231
+ Manual invalidation (rarely needed):
232
+
233
+ ```javascript
234
+ $.clearComponentCache('/components/Card.html'); // drop one template (query string ignored)
235
+ $.clearComponentCache(); // drop all cached templates
236
+ ```
237
+
238
+ > Dev note: `@ape-egg/vite-plugin-vibe` calls `$.clearComponentCache(path)` on hot update, so editing a component file is reflected immediately for both live and freshly-mounted instances — the runtime itself stays free of any dev-server coupling.
239
+
219
240
  ### Lifecycle Hooks
220
241
 
221
242
  ```javascript
@@ -1599,7 +1599,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1599
1599
 
1600
1600
  [[package]]
1601
1601
  name = "vibe-compiler"
1602
- version = "1.8.0"
1602
+ version = "1.9.0"
1603
1603
  dependencies = [
1604
1604
  "clap",
1605
1605
  "colored",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vibe-compiler"
3
- version = "1.8.0"
3
+ version = "1.9.0"
4
4
  edition = "2021"
5
5
  description = "Vibe framework compiler - compiles Vibe source files into optimized output"
6
6
  authors = ["Kim Korte"]
@@ -12,6 +12,32 @@ use rayon::prelude::*;
12
12
  use crate::config::Config;
13
13
  use crate::parser::HtmlParser;
14
14
 
15
+ /// Map an output-relative HTML path to the URL path the runtime resolves a
16
+ /// manifest from. Strips the `root` dir (served at /) and collapses dynamic
17
+ /// `$param` segments to a `$` token, e.g. with root `pages`:
18
+ /// pages/armory.html -> armory.html
19
+ /// pages/the-arena/$id.html -> the-arena/$.html
20
+ fn manifest_url_path(relative_path: &str, root: Option<&str>) -> String {
21
+ let stripped = match root {
22
+ Some(r) if relative_path == r => "",
23
+ Some(r) if relative_path.starts_with(&format!("{}/", r)) => &relative_path[r.len() + 1..],
24
+ _ => relative_path,
25
+ };
26
+
27
+ stripped
28
+ .split('/')
29
+ .map(|seg| match seg.strip_prefix('$') {
30
+ // $id.html -> $.html ; $id -> $
31
+ Some(rest) => match rest.find('.') {
32
+ Some(dot) => format!("${}", &rest[dot..]),
33
+ None => "$".to_string(),
34
+ },
35
+ None => seg.to_string(),
36
+ })
37
+ .collect::<Vec<_>>()
38
+ .join("/")
39
+ }
40
+
15
41
  // =============================================================================
16
42
  // MIRROR_MODE: Copy asset files from source to output as-is, preserving
17
43
  // directory structure. HTML files are compiled separately.
@@ -38,7 +64,10 @@ const MIRROR_EXTENSIONS: &[&str] = &[
38
64
  "json", "xml", "csv",
39
65
  ];
40
66
 
41
- // Files and directories to skip when walking source (supports glob patterns)
67
+ // Built-in files and directories to skip when walking source (supports glob
68
+ // patterns). User-supplied `skipFiles` are appended to these in Config::load,
69
+ // so the effective list lives on `config.skip_files` and is passed into
70
+ // should_skip_path() at every call site.
42
71
  // Note: Output directory is checked dynamically (not hardcoded here)
43
72
  // Note: Dotfiles are handled by starts_with('.') check in should_skip_path()
44
73
  pub const SKIP_FILES: &[&str] = &[
@@ -53,20 +82,21 @@ pub const SKIP_FILES: &[&str] = &[
53
82
  "**/*.config.ts", // Config files
54
83
  ];
55
84
 
56
- /// Check if a path should be skipped based on SKIP_FILES patterns
57
- pub fn should_skip_path(path: &Path, name: &str) -> bool {
85
+ /// Check if a path should be skipped based on the effective skip patterns
86
+ /// (built-in SKIP_FILES + user `skipFiles`, merged in Config::load).
87
+ pub fn should_skip_path(path: &Path, name: &str, patterns: &[String]) -> bool {
58
88
  // Check if name starts with dot (dotfiles/directories)
59
89
  if name.starts_with('.') {
60
90
  return true;
61
91
  }
62
92
 
63
93
  // Check exact name match (for directories and simple filenames)
64
- if SKIP_FILES.contains(&name) {
94
+ if patterns.iter().any(|p| p == name) {
65
95
  return true;
66
96
  }
67
97
 
68
98
  // Check glob patterns (e.g., **/*.test.js)
69
- for pattern_str in SKIP_FILES {
99
+ for pattern_str in patterns {
70
100
  if pattern_str.contains('*') {
71
101
  if let Ok(pattern) = Pattern::new(pattern_str) {
72
102
  // Try matching against just the filename
@@ -794,6 +824,7 @@ impl Compiler {
794
824
  self.config.iterations_as_is,
795
825
  self.config.components_as_is,
796
826
  &self.config.source,
827
+ self.config.root.as_deref(),
797
828
  ) {
798
829
  Ok(()) => {
799
830
  pages_processed += 1;
@@ -826,6 +857,7 @@ impl Compiler {
826
857
  iterations_as_is: bool,
827
858
  components_as_is: bool,
828
859
  source_root: &Path,
860
+ manifest_root: Option<&str>,
829
861
  ) -> Result<(), String> {
830
862
  use crate::compiler::manifest_builder::ManifestBuilder;
831
863
  use crate::compiler::component_tagger::ComponentTagger;
@@ -841,10 +873,17 @@ impl Compiler {
841
873
  let manifest_builder = ManifestBuilder::new();
842
874
  let manifest = manifest_builder.build_from_html(html, &state, iterations_as_is)?;
843
875
 
876
+ // Map the output-relative path to the served-URL path: drop the `root`
877
+ // dir (the folder served at /, e.g. `pages`) and collapse dynamic
878
+ // `$param` segments to a single `$` token. This lets the runtime resolve
879
+ // a manifest from a clean URL — `/armory` and `/the-arena/123` find
880
+ // `armory.html.manifest.js` and `the-arena/$.html.manifest.js`.
881
+ let manifest_rel = manifest_url_path(relative_path, manifest_root);
882
+
844
883
  // Write manifest
845
884
  let manifest_path = output_dir
846
885
  .join("vibe-hyperspeed")
847
- .join(format!("{}.manifest.js", relative_path));
886
+ .join(format!("{}.manifest.js", manifest_rel));
848
887
 
849
888
  // Ensure directory exists
850
889
  if let Some(parent) = manifest_path.parent() {
@@ -857,7 +896,7 @@ impl Compiler {
857
896
 
858
897
  let manifest_js = format!(
859
898
  "// Pre-compiled manifest for /{}\n// Generated by Vibe compiler\n\nexport default {};\n",
860
- relative_path,
899
+ manifest_rel,
861
900
  manifest_json
862
901
  );
863
902
 
@@ -901,6 +940,7 @@ impl Compiler {
901
940
  let verbose = self.verbose;
902
941
  let iterations_as_is = self.config.iterations_as_is;
903
942
  let components_as_is = self.config.components_as_is;
943
+ let manifest_root = self.config.root.clone();
904
944
 
905
945
  let results: Vec<_> = html_files
906
946
  .par_iter()
@@ -917,7 +957,7 @@ impl Compiler {
917
957
  };
918
958
 
919
959
  // Try to generate manifest for this file (skip on error)
920
- match Self::generate_file_manifest(&html, html_path, &output_dir, relative_path, verbose, iterations_as_is, components_as_is, &source_root) {
960
+ match Self::generate_file_manifest(&html, html_path, &output_dir, relative_path, verbose, iterations_as_is, components_as_is, &source_root, manifest_root.as_deref()) {
921
961
  Ok(()) => (true, None),
922
962
  Err(e) => {
923
963
  if verbose {
@@ -974,7 +1014,7 @@ impl Compiler {
974
1014
  }
975
1015
 
976
1016
  // Skip special directories
977
- if should_skip_path(&path, file_name) {
1017
+ if should_skip_path(&path, file_name, &self.config.skip_files) {
978
1018
  continue;
979
1019
  }
980
1020
 
@@ -1059,7 +1099,7 @@ impl Compiler {
1059
1099
  }
1060
1100
 
1061
1101
  // Skip special directories
1062
- if should_skip_path(&path, file_name) {
1102
+ if should_skip_path(&path, file_name, &self.config.skip_files) {
1063
1103
  continue;
1064
1104
  }
1065
1105
 
@@ -1093,7 +1133,7 @@ impl Compiler {
1093
1133
  self.process_directory_assets_only(&path, &new_relative, canonical_output, canonical_source, stats)?;
1094
1134
  } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
1095
1135
  // Skip files matching skip patterns
1096
- if should_skip_path(&path, file_name) {
1136
+ if should_skip_path(&path, file_name, &self.config.skip_files) {
1097
1137
  continue;
1098
1138
  }
1099
1139
 
@@ -1546,7 +1586,7 @@ impl Compiler {
1546
1586
  let file_name_str = file_name.to_string_lossy();
1547
1587
 
1548
1588
  // Skip specific directories/patterns (includes dotfiles via SKIP_FILES)
1549
- if should_skip_path(&path, &file_name_str) {
1589
+ if should_skip_path(&path, &file_name_str, &self.config.skip_files) {
1550
1590
  continue;
1551
1591
  }
1552
1592
 
@@ -1765,7 +1805,7 @@ impl Compiler {
1765
1805
  let file_name = path.file_name().unwrap().to_str().unwrap();
1766
1806
 
1767
1807
  // Skip files/directories matching skip patterns
1768
- if should_skip_path(&path, file_name) {
1808
+ if should_skip_path(&path, file_name, &self.config.skip_files) {
1769
1809
  continue;
1770
1810
  }
1771
1811
 
@@ -1945,7 +1985,16 @@ fn minify_html(html: &str) -> String {
1945
1985
  if in_pre {
1946
1986
  result.push_str(line);
1947
1987
  result.push('\n');
1988
+ last_was_space = false;
1948
1989
  } else {
1990
+ // The line break preceding this line is whitespace: collapse it to a
1991
+ // single space so attributes/text split across lines don't glue
1992
+ // together (e.g. a multi-line <meta name=... content=...> tag).
1993
+ // The >\s+< pass below re-tightens genuine tag boundaries.
1994
+ if !last_was_space && !result.is_empty() {
1995
+ result.push(' ');
1996
+ last_was_space = true;
1997
+ }
1949
1998
  for c in trimmed.chars() {
1950
1999
  if c.is_whitespace() {
1951
2000
  if !last_was_space {
@@ -128,11 +128,11 @@ fn to_kebab_case(s: &str) -> String {
128
128
 
129
129
  /// Check if a path should be blacklisted based on SKIP_FILES patterns
130
130
  /// This checks both the filename and all path components relative to source root
131
- fn is_path_blacklisted(path: &Path, source_root: &Path) -> bool {
131
+ fn is_path_blacklisted(path: &Path, source_root: &Path, skip_files: &[String]) -> bool {
132
132
  let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
133
133
 
134
134
  // Check filename against blacklist
135
- if should_skip_path(path, file_name) {
135
+ if should_skip_path(path, file_name, skip_files) {
136
136
  return true;
137
137
  }
138
138
 
@@ -140,7 +140,7 @@ fn is_path_blacklisted(path: &Path, source_root: &Path) -> bool {
140
140
  if let Ok(relative) = path.strip_prefix(source_root) {
141
141
  for component in relative.components() {
142
142
  if let Some(component_str) = component.as_os_str().to_str() {
143
- if should_skip_path(path, component_str) {
143
+ if should_skip_path(path, component_str, skip_files) {
144
144
  return true;
145
145
  }
146
146
  }
@@ -159,7 +159,7 @@ pub fn build_dependency_graph(config: &Config) -> std::result::Result<Dependency
159
159
  .unwrap_or_else(|_| config.output.clone());
160
160
 
161
161
  // Scan all HTML files in source directory
162
- scan_directory(&config.source, &config.source, &config.components, &canonical_output, &mut graph)?;
162
+ scan_directory(&config.source, &config.source, &config.components, &canonical_output, &config.skip_files, &mut graph)?;
163
163
 
164
164
  Ok(graph)
165
165
  }
@@ -169,6 +169,7 @@ fn scan_directory(
169
169
  source_root: &Path,
170
170
  components_dir: &str,
171
171
  output_dir: &Path,
172
+ skip_files: &[String],
172
173
  graph: &mut DependencyGraph,
173
174
  ) -> std::result::Result<(), std::io::Error> {
174
175
  if !dir.is_dir() {
@@ -190,11 +191,11 @@ fn scan_directory(
190
191
  }
191
192
 
192
193
  // Skip directories using shared skip logic
193
- if should_skip_path(&path, file_name) {
194
+ if should_skip_path(&path, file_name, skip_files) {
194
195
  continue;
195
196
  }
196
197
 
197
- scan_directory(&path, source_root, components_dir, output_dir, graph)?;
198
+ scan_directory(&path, source_root, components_dir, output_dir, skip_files, graph)?;
198
199
  } else if let Some(ext) = path.extension() {
199
200
  if ext == "html" {
200
201
  let html = std::fs::read_to_string(&path)?;
@@ -354,7 +355,7 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
354
355
  for event in events {
355
356
  for path in &event.paths {
356
357
  // Skip blacklisted files/directories (check entire path, not just filename)
357
- if is_path_blacklisted(path, &config.source) {
358
+ if is_path_blacklisted(path, &config.source, &config.skip_files) {
358
359
  continue;
359
360
  }
360
361
 
@@ -381,7 +382,7 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
381
382
  }
382
383
 
383
384
  // Skip blacklisted files/directories
384
- if is_path_blacklisted(path, &config.source) {
385
+ if is_path_blacklisted(path, &config.source, &config.skip_files) {
385
386
  continue;
386
387
  }
387
388
 
@@ -487,7 +488,7 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
487
488
  }
488
489
 
489
490
  // Skip blacklisted files/directories
490
- if is_path_blacklisted(path, &config.source) {
491
+ if is_path_blacklisted(path, &config.source, &config.skip_files) {
491
492
  continue;
492
493
  }
493
494
 
@@ -35,6 +35,8 @@ pub struct VibeCompilerConfig {
35
35
  #[serde(default)]
36
36
  pub reserved_elements: Vec<String>,
37
37
  #[serde(default)]
38
+ pub skip_files: Vec<String>,
39
+ #[serde(default)]
38
40
  pub node_modules_as_is: bool,
39
41
  #[serde(default)]
40
42
  pub components_as_is: bool,
@@ -96,6 +98,7 @@ impl Default for VibeCompilerConfig {
96
98
  elements_as_is: false,
97
99
  source_maps: false,
98
100
  reserved_elements: vec![],
101
+ skip_files: vec![],
99
102
  node_modules_as_is: false,
100
103
  components_as_is: false,
101
104
  runtime_as_is: false,
@@ -115,11 +118,12 @@ pub struct Config {
115
118
  pub components: String,
116
119
  pub pages: String,
117
120
  pub _assets: String,
118
- pub _root: Option<String>,
121
+ pub root: Option<String>,
119
122
  pub minify: bool,
120
123
  pub elements_as_is: bool,
121
124
  pub source_maps: bool,
122
125
  pub reserved_elements: Vec<String>,
126
+ pub skip_files: Vec<String>,
123
127
  pub node_modules_as_is: bool,
124
128
  pub components_as_is: bool,
125
129
  pub runtime_as_is: bool,
@@ -154,6 +158,13 @@ impl Config {
154
158
  let mut reserved_elements = get_default_reserved_elements();
155
159
  reserved_elements.extend(config.reserved_elements);
156
160
 
161
+ // Combine built-in skip patterns with user-provided ones (append, not replace)
162
+ let mut skip_files: Vec<String> = crate::compiler::compile::SKIP_FILES
163
+ .iter()
164
+ .map(|s| s.to_string())
165
+ .collect();
166
+ skip_files.extend(config.skip_files);
167
+
157
168
  Self {
158
169
  source,
159
170
  output,
@@ -162,11 +173,12 @@ impl Config {
162
173
  components: config.components,
163
174
  pages: config.pages,
164
175
  _assets: config.assets,
165
- _root: config.root,
176
+ root: config.root,
166
177
  minify: config.minify,
167
178
  elements_as_is: config.elements_as_is,
168
179
  source_maps: config.source_maps,
169
180
  reserved_elements,
181
+ skip_files,
170
182
  node_modules_as_is: config.node_modules_as_is,
171
183
  components_as_is: config.components_as_is,
172
184
  runtime_as_is: config.runtime_as_is,
@@ -204,6 +204,14 @@ fn main() {
204
204
  }
205
205
  }
206
206
 
207
+ fn format_list_preview(items: &[String]) -> String {
208
+ if items.len() <= 2 {
209
+ format!("{:?}", items)
210
+ } else {
211
+ format!("[{:?}, {:?}, ... + {} more]", items[0], items[1], items.len() - 2)
212
+ }
213
+ }
214
+
207
215
  // Alphabetically ordered with padding (longest key is "reservedElements" = 16 chars)
208
216
  println!(" {}: {}", format!("{:<16}", "assets").cyan(), format_value_no_flag(&config._assets));
209
217
  println!(" {}: {}", format!("{:<16}", "components").cyan(), format_value_no_flag(&config.components));
@@ -215,8 +223,9 @@ fn main() {
215
223
  println!(" {}: {}", format!("{:<16}", "output").cyan(), format_value_no_flag(&config._output_str));
216
224
  println!(" {}: {}", format!("{:<16}", "pages").cyan(), format_value_no_flag(&config.pages));
217
225
  println!(" {}: {}", format!("{:<16}", "reservedElements").cyan(), format_value_no_flag(format_reserved_elements(&config.reserved_elements)));
218
- println!(" {}: {}", format!("{:<16}", "root").cyan(), format_value_no_flag(config._root.as_ref().map(|s| s.as_str()).unwrap_or("null")));
226
+ println!(" {}: {}", format!("{:<16}", "root").cyan(), format_value_no_flag(config.root.as_ref().map(|s| s.as_str()).unwrap_or("null")));
219
227
  println!(" {}: {}", format!("{:<16}", "runtimeAsIs").cyan(), format_bool_with_flag(config.runtime_as_is, overrides.runtime_as_is, original_runtime_as_is));
228
+ println!(" {}: {}", format!("{:<16}", "skipFiles").cyan(), format_value_no_flag(format_list_preview(&config.skip_files)));
220
229
  println!(" {}: {}", format!("{:<16}", "source").cyan(), format_value_no_flag(&config._source_str));
221
230
  println!(" {}: {}", format!("{:<16}", "sourceMaps").cyan(), format_bool_with_flag(config.source_maps, overrides.source_maps, original_source_maps));
222
231
  println!();
package/llms.txt CHANGED
@@ -289,7 +289,7 @@ window.$ = vibe(
289
289
 
290
290
  **Parameters:**
291
291
  - `initialState` — object containing initial state values
292
- - `config` — optional object. Currently supports `{ debug: boolean }`. A third positional argument can pass a target selector (defaults to `body`).
292
+ - `config` — optional object. Supports `{ debug: boolean, noCache: boolean }` (`noCache` disables the component template cache — components are otherwise fetched once per `src` and reused across instances/navigation). A third positional argument can pass a target selector (defaults to `body`).
293
293
 
294
294
  **Returns:** Reactive proxy. Assign it to `window.$` so inline event handlers and bindings can find it.
295
295
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "2.0.5",
3
+ "version": "2.1.3",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",
@@ -0,0 +1,94 @@
1
+ // Component template cache.
2
+ //
3
+ // Vibe loads each `<component src="...">` by fetching its HTML template. A page
4
+ // commonly mounts the same component many times (a list of cards, a row of
5
+ // stat bars), and an SPA re-mounts components on every navigation. Without a
6
+ // cache, each instance — and each revisit — refetches an identical template,
7
+ // and a burst of same-tick mounts stampedes the network with N concurrent
8
+ // requests for one file.
9
+ //
10
+ // This module dedupes those fetches by `src`:
11
+ // - concurrent mounts in the same tick share one in-flight request, because
12
+ // the PROMISE (not the resolved text) is cached synchronously before the
13
+ // first await — so callers coalesce onto it instead of each starting their own
14
+ // - later mounts (including after SPA navigation) resolve from memory, with
15
+ // no network request at all — the one win a browser HTTP cache cannot
16
+ // provide, since it revalidates per request and never coalesces concurrent ones
17
+ //
18
+ // The cache is session-lived and content-busted, never time-busted. In
19
+ // production a component template is immutable for the life of the page (it
20
+ // only changes on redeploy, which is a new session anyway), so there is nothing
21
+ // to invalidate. In development, tooling busts entries on file change via
22
+ // `clearComponentCache(path)` — which keeps this module free of any dev/HMR
23
+ // coupling; it never references the dev server or its events.
24
+ //
25
+ // Disable entirely with `vibe(state, { noCache: true })`.
26
+
27
+ let enabled = true;
28
+
29
+ // src -> Promise<string> (raw template HTML). Stores the in-flight promise so
30
+ // concurrent callers coalesce; the resolved value is held by the promise, so a
31
+ // settled entry is an instant cache hit on every later read.
32
+ const templates = new Map();
33
+
34
+ // Configure from the runtime config (`{ noCache }`). Called once at boot. When
35
+ // caching is turned off we also drop anything already cached, so toggling at
36
+ // runtime (e.g. between test cases) can't serve a stale hit.
37
+ export const configureComponentCache = (config = {}) => {
38
+ enabled = !config?.noCache;
39
+ if (!enabled) templates.clear();
40
+ };
41
+
42
+ export const isComponentCacheEnabled = () => enabled;
43
+
44
+ // True when `src` will resolve without a new network request — either a settled
45
+ // template or an in-flight request this mount coalesces onto. Callers capture
46
+ // this BEFORE fetchComponentTemplate so the debug layer can distinguish a real
47
+ // network fetch from a cache hit.
48
+ export const isComponentCached = (src) => enabled && templates.has(src);
49
+
50
+ // Fetch a component template, deduped by `src`. Returns a Promise<string>.
51
+ //
52
+ // `signal` aborts the request when the host element is removed. It is honored
53
+ // only on the uncached path: a shared cached fetch must NOT be aborted by one
54
+ // element unmounting while other elements still await the same template. The
55
+ // caller already re-checks `el.parentNode` after the fetch settles, so dropping
56
+ // the abort on the shared path costs nothing but a tiny, redundant download.
57
+ export const fetchComponentTemplate = (src, signal) => {
58
+ if (!enabled) {
59
+ return fetch(src, { signal }).then((response) => response.text());
60
+ }
61
+
62
+ let entry = templates.get(src);
63
+ if (!entry) {
64
+ entry = fetch(src).then(async (response) => {
65
+ const text = await response.text();
66
+ // Never persist a failed response — the immediate caller still gets the
67
+ // body (parity with the uncached path), but the next mount may retry.
68
+ if (!response.ok) templates.delete(src);
69
+ return text;
70
+ });
71
+ // Cache synchronously, before the first await, so same-tick concurrent
72
+ // mounts find this pending entry and coalesce onto it.
73
+ templates.set(src, entry);
74
+ // Drop the entry if the fetch rejects, so a transient network error isn't
75
+ // sticky for the rest of the session.
76
+ entry.catch(() => templates.delete(src));
77
+ }
78
+ return entry;
79
+ };
80
+
81
+ // Invalidate cached templates. With a `path`, drops that one entry (the query
82
+ // string is ignored when matching, so `/components/Foo.html` also clears a
83
+ // versioned `/components/Foo.html?v=…`); with no argument, clears everything.
84
+ // Exposed publicly as `$.clearComponentCache` for tooling to call on change.
85
+ export const clearComponentCache = (path) => {
86
+ if (!path) {
87
+ templates.clear();
88
+ return;
89
+ }
90
+ const base = path.split('?')[0];
91
+ for (const key of templates.keys()) {
92
+ if (key.split('?')[0] === base) templates.delete(key);
93
+ }
94
+ };
@@ -1,12 +1,14 @@
1
1
  import { debugLog } from './debug.js';
2
2
  import {
3
3
  PHASE_FETCH,
4
+ PHASE_FETCH_CACHED,
4
5
  DEHYDRATE_CLASS_OR_ATTR,
5
6
  BINDING_REGEX,
6
7
  THIS_PROP_REGEX,
7
8
  STATE_THIS_PROP_REGEX,
8
9
  } from './constants.js';
9
10
  import { evalInScope } from './utils.js';
11
+ import { fetchComponentTemplate, isComponentCached } from './component-cache.js';
10
12
 
11
13
  // Deterministic component counter
12
14
  let componentCounter = 0;
@@ -432,12 +434,16 @@ const processSingle = (el, debug) => {
432
434
  }
433
435
  });
434
436
 
437
+ // Capture cache state before the fetch so the debug layer can tell a real
438
+ // network fetch from a runtime-cache hit (the call below would make them
439
+ // indistinguishable — both just resolve a promise).
440
+ const fromCache = isComponentCached(src);
441
+
435
442
  // Create AbortController to cancel fetch if element is removed
436
443
  const controller = new AbortController();
437
444
  pendingFetches.set(el, controller);
438
445
 
439
- return fetch(src, { signal: controller.signal })
440
- .then((r) => r.text())
446
+ return fetchComponentTemplate(src, controller.signal)
441
447
  .then((html) => {
442
448
  // Parse HTML in temporary container to process component scripts
443
449
  const temp = createDetached('div');
@@ -589,7 +595,7 @@ const processSingle = (el, debug) => {
589
595
  // each row update.
590
596
  el._vibeReplacedBy = newWrapper;
591
597
  el.replaceWith(newWrapper);
592
- debugLog(PHASE_FETCH, src, debug);
598
+ debugLog(fromCache ? PHASE_FETCH_CACHED : PHASE_FETCH, src, debug);
593
599
 
594
600
  // MutationObserver handles parsing and hydrating the new content.
595
601
  // Branch nodes are registered in the manifest by mountBranch,
@@ -15,7 +15,8 @@ export const PHASE_PARSE = 'Parsed'; // Reads DOM structure (parse.js)
15
15
  export const PHASE_HYDRATE = 'Hydrated'; // Replaces @[...] with values (hydrate.js)
16
16
  export const PHASE_ITERATE = 'Iterated'; // Renders <!-- each --> blocks (iterate.js)
17
17
  export const PHASE_CONDITION = 'Evaluated'; // Renders <!-- if --> blocks (conditionals.js)
18
- export const PHASE_FETCH = 'Fetched'; // Loads <component> content (component.js)
18
+ export const PHASE_FETCH = 'Fetched'; // Loads <component> content over the network (component.js)
19
+ export const PHASE_FETCH_CACHED = 'Fet(ca)ched'; // Loads <component> content from the runtime template cache — a Fetch served from memory (component.js)
19
20
  export const PHASE_UPDATE = 'Proxy'; // State changes trigger re-hydration (index.js)
20
21
  export const PHASE_MUTATE = 'Mutation'; // DOM mutations detected by observer (index.js)
21
22
  export const PHASE_HYPERSPEED = 'Hyperspeed'; // Restores @[...] markers from pre-compiled manifest (pre-compiled-manifest.js)
package/runtime/debug.js CHANGED
@@ -12,6 +12,7 @@ const PHASE_COLORS = {
12
12
  Iterated: 'oklch(0.58 0.11 142)', // comment green (baseline)
13
13
  Evaluated: 'oklch(0.58 0.11 142)', // comment green (baseline)
14
14
  Fetched: 'oklch(0.59 0.12 307)', // muted purple
15
+ 'Fet(ca)ched': 'oklch(0.59 0.12 307)', // same muted purple — a Fetch served from the runtime cache
15
16
  Mutation: 'oklch(0.60 0.11 240)', // muted blue
16
17
  Proxy: 'oklch(0.60 0.11 240)', // muted blue
17
18
  Hyperspeed: 'oklch(0.60 0.11 180)', // muted cyan
@@ -39,7 +40,7 @@ const COLORS = {
39
40
  export const debugLog = (phase, message, debug = false, indent = 0, element = null) => {
40
41
  if (!debug) return;
41
42
 
42
- const phaseBracket = `[${phase}] `.padEnd(13, ' '); // Pad to 13 chars (longest is "[Manifested] ")
43
+ const phaseBracket = `[${phase}] `.padEnd(14, ' '); // Pad to 14 chars (longest is "[Fet(ca)ched] ")
43
44
  const indentStr = indent > 0 ? ' '.repeat(indent) + '├─ ' : '';
44
45
  const phaseColor = PHASE_COLORS[phase] || 'oklch(0.55 0.02 250)';
45
46
 
package/runtime/index.js CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  DEHYDRATE_CLASS_OR_ATTR,
25
25
  } from './constants.js';
26
26
  import { processComponent, abortComponentFetch, collectComponentIds, releaseOrphanedComponentState, renderComponentTemplate, executeCompiledComponentScripts } from './component.js';
27
+ import { configureComponentCache, clearComponentCache } from './component-cache.js';
27
28
  import { debugLog } from './debug.js';
28
29
  import { shouldCleanup, cleanup } from './cleanup.js';
29
30
  import { reconcile } from './reconcile.js';
@@ -331,6 +332,9 @@ const main = (s, config = {}, stringSelector = '') => {
331
332
  const verbose = !!config?.verbose;
332
333
  globalThis.__vibeDebug = debug;
333
334
 
335
+ // Enable/disable the component template cache from config (`{ noCache }`).
336
+ configureComponentCache(config);
337
+
334
338
  // Expose the global `$scope` resolver used by loop-scoped `on*` handlers.
335
339
  installScopeResolver();
336
340
 
@@ -676,6 +680,17 @@ const main = (s, config = {}, stringSelector = '') => {
676
680
  enumerable: false,
677
681
  });
678
682
 
683
+ // Invalidate cached component templates. `$.clearComponentCache(path)` drops
684
+ // one entry, `$.clearComponentCache()` drops all. Templates are immutable in
685
+ // production (nothing to clear), so this exists for tooling that swaps a
686
+ // template under a live session — e.g. the dev server busts the changed file
687
+ // on hot update. Non-enumerable so it never leaks into state snapshots.
688
+ Object.defineProperty($, 'clearComponentCache', {
689
+ value: clearComponentCache,
690
+ enumerable: false,
691
+ configurable: true,
692
+ });
693
+
679
694
  // Expose the live reactive proxy to the iteration stamper so loop-scoped
680
695
  // `on*` handlers (`$scope`) resolve the SAME object identity the app sees via
681
696
  // `$`, instead of the plain diff-snapshot clones iterations render against
@@ -193,6 +193,17 @@ const detectHyperspeed = async () => {
193
193
  );
194
194
  }
195
195
 
196
+ // Strategy 4: dynamic routes. The compiler collapses a `$param` segment to a
197
+ // single `$` token (the-arena/$id.html -> the-arena/$.html.manifest.js), so a
198
+ // concrete URL only matches once its trailing segment is tokenized. Tried
199
+ // after the literal strategies, so static pages still win on an exact hit.
200
+ const dot = fileName.indexOf(".");
201
+ const tokenized = dot >= 0 ? "$" + fileName.slice(dot) : "$";
202
+ if (tokenized !== fileName) {
203
+ const dirPrefix = dirSegments.length ? `/${dirSegments.join("/")}` : "";
204
+ possiblePaths.push(`/vibe-hyperspeed${dirPrefix}/${tokenized}.manifest.js`);
205
+ }
206
+
196
207
  if (!skipNetwork) {
197
208
  // Fully-runtime dynamic import. Hidden behind `new Function` so any
198
209
  // bundler's static-analysis can't read into it — there's nothing we