@ape-egg/vibe 2.1.3 → 2.1.6

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,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.1.6] - 2026-06-19
4
+
5
+ ### Added
6
+
7
+ - **Compiler 1.9.2 → 1.9.3 — constant-vs-dynamic state analysis before value-stamping** (`compiler/src/compiler/reassignment_analyzer.rs` (new), `value_stamper.rs`, `compile.rs`, `compiler/mod.rs`) — the compiler now proves which global `$` state keys are compile-time constants before baking them into pre-rendered HTML. A key is stamped only when its initial value is a primitive (string/number/bool/null) **and** nothing anywhere writes it (assignment, compound, `++`/`--`, `delete`, or a nested `$.key.x = …`); every other key stays a live `@[...]` binding for the runtime to fill. The rule is deliberately one-sided: a wrong "constant" bakes stale content into production HTML (a correctness bug), a wrong "dynamic" only forgoes the optimization (a brief FOUC), so every uncertain case resolves to dynamic. Whole-program escape signals discard the optimization entirely — aliasing `$` itself (`const x = $`), reflective writes (`Object.assign($, …)`, `Object.defineProperty`), or any source that fails to parse. The `$.key` / `$['key']` (global) vs `$[expr]` (component-state-by-id) split mirrors Vibe's own convention, so a computed component-state write is ignored rather than treated as an escape. Objects and arrays are never constant (a value alias like `const a = $.items; a.push(x)` can mutate them invisibly). Built on an SWC AST visitor with broad unit coverage (`reassignment_analyzer.rs` tests).
8
+
9
+ ### Fixed
10
+
11
+ - **Compiler 1.9.2 → 1.9.3 — state initialized from imports could not be resolved** (`compiler/src/compiler/js_analyzer.rs`) — the state extractor now follows default-import chains across files (`resolve_import_value` / `resolve_export_from_file` / `visit_export_default_expr`), so a `$` key whose initial value comes from an imported module resolves to its real value for stamping instead of being treated as unknown. Also tags components whose state object is built at runtime.
12
+ - **Compiler 1.9.2 → 1.9.3 — `>` inside a prop binding broke component / custom-element inlining** (`compiler/src/parser/html.rs`, `component_tagger.rs`) — the inliner's attribute-run pattern was `[^>]*`, so a comparison inside a quoted binding value (`flipped="@[selectedBrawlers.length >= maxBrawlers]"`) truncated the open tag at that inner `>`, mis-parsing every following attribute and mangling the element. The attribute run now mirrors a real HTML tokenizer (`ATTR_RUN`) — a tag ends only on an *unquoted* `>`, so `>`/`>=` inside any quoted value is part of the value. Repro: `inline_component_tolerates_gt_in_prop_binding`, `inline_custom_element_tolerates_gt_in_prop_binding`.
13
+
14
+ ## [2.1.5] - 2026-06-19
15
+
16
+ ### Fixed
17
+
18
+ - **Route-aware manifest resolution for dynamic pages** (`runtime/pre-compiled-manifest.js`) — the candidate-path builder is extracted into an exported, unit-tested `buildManifestCandidatePaths(pathname, route)` and now reads `window.__ROUTE__` (the route template the compiler/dev server injects for dynamic pages, e.g. `/brawlers/:index`). When the route marks a segment dynamic with `:param`, the compiler has already collapsed that segment to `$` in the manifest path, so the runtime points straight at the tokenized manifest (`/vibe-hyperspeed/brawlers/$.html.manifest.js`) first instead of probing literal URLs (`/brawlers/0.html.manifest.js`) that are guaranteed to 404. Params can sit mid-path (`/a/:id/b`), and the candidate list is de-duplicated (subdirectory pages otherwise produced the same URL twice). Static pages with no route hint behave exactly as before. Tests: `runtime/pre-compiled-manifest.test.mjs`.
19
+ - **Compiler 1.9.1 → 1.9.2 — component `<script>` source was HTML-escaped, dropping `component(stateVar)` components** (`compiler/src/compiler/component_tagger.rs`) — re-serializing a `<script>` element from its children loses the rawtext context, so JS operators were entity-escaped (`>` → `&gt;`, `&&` → `&amp;&amp;`). The mangled source then failed to parse, demoting the component to the regex fallback (which only matches `component({` object literals) and leaving a component called with a bare variable untagged. The script's text is now read directly. Adds AST-analyzer coverage for `component(stateVar)` resolution against a realistic script shape (imports, dynamic `import().then()`, `catch {}`, optional chaining).
20
+ - **Compiler 1.9.1 → 1.9.2 — component-local `this.` was only resolved in simple text/attribute bindings** (`compiler/src/compiler/component_tagger.rs`) — build-time `this.` → component-id rewriting now also covers `if`/`each`/`else if` directive comments, nested binding paths (`@[this.x.y]`), multi-reference expressions, and `$.this.X` writes in event-handler bodies (`onclick="$.this.mode = 'edit'"`) — mirroring the runtime's `STATE_THIS_PROP_REGEX` pass. Without this, compiled (manifest-restored) pages — which can't fall back to a `data-vibe-component-id` ancestor lookup — shipped literal `this.`/`$.this.` that resolved to `undefined`. Loop aliases (`each _c0.items as it`) are left untouched.
21
+
22
+ ## [2.1.4] - 2026-06-18
23
+
24
+ ### Fixed
25
+
26
+ - **Compiler 1.9.0 → 1.9.1 — `--minify` broke `<script>` and `<style>` blocks** (`compiler/src/compiler/compile.rs`) — `minify_html` only treated `<pre>` as whitespace-significant. Collapsing newlines inside a `<script>` turned a `//` line comment (or any ASI-dependent break) into a single line, so the comment swallowed the rest of the script and `new Function` threw a `SyntaxError` at runtime; `<style>` blocks were likewise flattened. Both tags now join `<pre>` as raw blocks emitted line-for-line, while the surrounding HTML still minifies normally. Repro: `minify_preserves_script_newlines_so_line_comments_dont_swallow_code`, `minify_preserves_style_newlines` (`compile.rs` unit tests).
27
+ - **Compiler 1.9.0 → 1.9.1 — component inliner overshot on end tags split across lines** (`compiler/src/parser/html.rs`) — `find_matching_close` matched `</tag>` exactly, but whitespace-controlled markup can split an end tag (`</component\n>`), which HTML permits. The depth counter then counted the nested open without ever seeing its close, so the outer-close search ran past the real boundary and swallowed every following sibling into the component. The close-tag regex now tolerates whitespace before `>` (`</tag\s*>`); `\s*` can't bridge into `</tag-foo>`, so matching stays exact on the tag name. Repro: `find_matching_close_tolerates_whitespace_in_end_tag` (`html.rs` unit test).
28
+
3
29
  ## [2.1.3] - 2026-06-18
4
30
 
5
31
  ### Added
package/README.md CHANGED
@@ -491,6 +491,54 @@ The compiler generates optimized batch functions for `<!-- each -->` loops, prov
491
491
 
492
492
  **Opt-out:** Set `iterationsAsIs: true` to use runtime rendering for all iterations.
493
493
 
494
+ ### Pre-rendered Global State (write state the way the compiler expects)
495
+
496
+ To eliminate the flash of raw `@[...]` markers, the compiler **bakes** any global `$`
497
+ value it can prove is constant straight into the pre-rendered HTML. `BETA v@[version]`
498
+ ships as `BETA v0.1.5`. Everything else stays a live binding the runtime fills on
499
+ hydration.
500
+
501
+ A global key is baked **only** when both hold:
502
+
503
+ - **It's a primitive** — `string` / `number` / `boolean` / `null`. Objects and arrays
504
+ are never baked (their contents can be mutated through a reference the compiler
505
+ can't follow), so an `@[config.theme]` binding always stays live.
506
+ - **It's never reassigned** anywhere the compiler scans (every `.js` module, inline
507
+ `<script>`, and `on*` handler under `source`, minus `skipFiles`). Any `$.key = …`,
508
+ `$.key += …`, `$.key++`, `delete $.key`, or `$['key'] = …` marks it dynamic.
509
+
510
+ This is why **how you write a mutation matters** — write global state through a
511
+ **direct member assignment on `$`**:
512
+
513
+ ```js
514
+ $.coins = gameState.coins; // ✅ seen → `coins` stays a live binding
515
+ ```
516
+
517
+ If you mutate global state by a path static analysis can't follow, the compiler won't
518
+ see the write and may bake a stale initial value:
519
+
520
+ ```js
521
+ const s = $; s.coins = 5; // ⚠️ aliasing $ — disables baking for ALL keys (safe, but loses the optimization)
522
+ applyState($, patch); // ⚠️ if applyState does `arg.coins = …`, that write is INVISIBLE → `coins` may wrongly bake
523
+ ```
524
+
525
+ Passing `$` as a read-only argument (`derive($, opts)`) is fine; only writing through
526
+ the alias is the problem. Reach for `$.key = …` and the classifier stays correct.
527
+
528
+ The baked value comes from your `vibe({ ... })` initial state, resolved statically
529
+ (including default imports, e.g. `version: VERSION` → `version.js`'s `export default`).
530
+ If the initial value is a runtime call (`settings: loadLocalStorage(...)`), it simply
531
+ isn't baked — the binding stays live, which is harmless.
532
+
533
+ **Iterations follow the same rule.** An `<!-- each -->` over a global/dynamic array is
534
+ rendered **empty** in the compiled HTML (the raw `@[item.x]` template body is dropped,
535
+ not painted), and the runtime restores it from the manifest once the array has data.
536
+ Don't rely on a loop's template body existing in the DOM before hydration.
537
+
538
+ **Still use `vibe-fouc`.** Baking removes raw markers for *constant* values, but
539
+ session/runtime state (auth, server data, anything you reassign) can't be pre-rendered
540
+ — keep the `vibe-fouc` guard so that state doesn't flash either.
541
+
494
542
  ### Output Structure
495
543
 
496
544
  Mirrors source structure:
@@ -1599,7 +1599,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1599
1599
 
1600
1600
  [[package]]
1601
1601
  name = "vibe-compiler"
1602
- version = "1.9.0"
1602
+ version = "1.9.3"
1603
1603
  dependencies = [
1604
1604
  "clap",
1605
1605
  "colored",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vibe-compiler"
3
- version = "1.9.0"
3
+ version = "1.9.3"
4
4
  edition = "2021"
5
5
  description = "Vibe framework compiler - compiles Vibe source files into optimized output"
6
6
  authors = ["Kim Korte"]
@@ -8,6 +8,7 @@ use colored::Colorize;
8
8
  use regex::Regex;
9
9
  use glob::Pattern;
10
10
  use rayon::prelude::*;
11
+ use serde_json::{Map, Value};
11
12
 
12
13
  use crate::config::Config;
13
14
  use crate::parser::HtmlParser;
@@ -116,6 +117,80 @@ pub fn should_skip_path(path: &Path, name: &str, patterns: &[String]) -> bool {
116
117
  false
117
118
  }
118
119
 
120
+ /// Recursively gather JS sources that could write `$` state: every `.js` module
121
+ /// (full text — also a candidate for the global `vibe()` call) plus, from each
122
+ /// `.html` file, every inline `<script>` body and `on*` handler body. Skips the
123
+ /// compiled output directory and anything `should_skip_path` excludes.
124
+ fn collect_state_sources(
125
+ dir: &Path,
126
+ output_canon: Option<&Path>,
127
+ skip: &[String],
128
+ sources: &mut Vec<String>,
129
+ js_paths: &mut Vec<PathBuf>,
130
+ ) {
131
+ let entries = match fs::read_dir(dir) {
132
+ Ok(e) => e,
133
+ Err(_) => return,
134
+ };
135
+ for entry in entries.flatten() {
136
+ let path = entry.path();
137
+ let name = match path.file_name().and_then(|n| n.to_str()) {
138
+ Some(n) => n,
139
+ None => continue,
140
+ };
141
+ if should_skip_path(&path, name, skip) {
142
+ continue;
143
+ }
144
+ if path.is_dir() {
145
+ // Never descend into the compiled output — its JS is bundled/minified
146
+ // and would falsely trip the analysis.
147
+ if let (Some(oc), Some(pc)) = (output_canon, path.canonicalize().ok()) {
148
+ if pc.as_path() == oc {
149
+ continue;
150
+ }
151
+ }
152
+ collect_state_sources(&path, output_canon, skip, sources, js_paths);
153
+ } else {
154
+ match path.extension().and_then(|e| e.to_str()) {
155
+ Some("js") => {
156
+ if let Ok(code) = fs::read_to_string(&path) {
157
+ sources.push(code);
158
+ js_paths.push(path);
159
+ }
160
+ }
161
+ Some("html") => {
162
+ if let Ok(html) = fs::read_to_string(&path) {
163
+ sources.extend(extract_inline_scripts(&html));
164
+ sources.extend(extract_event_handlers(&html));
165
+ }
166
+ }
167
+ _ => {}
168
+ }
169
+ }
170
+ }
171
+ }
172
+
173
+ /// Inner JS of every `<script>…</script>` block.
174
+ fn extract_inline_scripts(html: &str) -> Vec<String> {
175
+ let re = Regex::new(r"(?is)<script\b[^>]*>(.*?)</script>").unwrap();
176
+ re.captures_iter(html)
177
+ .map(|c| c[1].to_string())
178
+ .filter(|s| !s.trim().is_empty())
179
+ .collect()
180
+ }
181
+
182
+ /// Bodies of `on*="…"` / `on*='…'` event-handler attributes. The attribute-name
183
+ /// match is heuristic (it can catch a non-handler like `only="…"`), so each body
184
+ /// is kept only if it parses as JS — genuine handlers parse, false positives drop.
185
+ fn extract_event_handlers(html: &str) -> Vec<String> {
186
+ use crate::compiler::reassignment_analyzer::parses_as_js;
187
+ let re = Regex::new(r#"(?is)\son[a-z]+\s*=\s*(?:"([^"]*)"|'([^']*)')"#).unwrap();
188
+ re.captures_iter(html)
189
+ .filter_map(|c| c.get(1).or_else(|| c.get(2)).map(|m| m.as_str().to_string()))
190
+ .filter(|body| !body.trim().is_empty() && parses_as_js(body))
191
+ .collect()
192
+ }
193
+
119
194
  /// Rename `src="{component_src}"` to `data-vibe-recursive-src="{component_src}"`
120
195
  /// on `<component>` / `<div class="component">` tags. Used during cache build
121
196
  /// to neutralize cyclic refs before they reach `inline_component_elements` —
@@ -791,6 +866,7 @@ impl Compiler {
791
866
  let start = Instant::now();
792
867
  let mut pages_processed = 0;
793
868
  let mut pages_skipped = 0;
869
+ let global_constants = self.compute_global_constants();
794
870
 
795
871
  for file_path in files {
796
872
  // Convert source path to output path
@@ -825,6 +901,7 @@ impl Compiler {
825
901
  self.config.components_as_is,
826
902
  &self.config.source,
827
903
  self.config.root.as_deref(),
904
+ &global_constants,
828
905
  ) {
829
906
  Ok(()) => {
830
907
  pages_processed += 1;
@@ -858,6 +935,7 @@ impl Compiler {
858
935
  components_as_is: bool,
859
936
  source_root: &Path,
860
937
  manifest_root: Option<&str>,
938
+ global_constants: &Map<String, Value>,
861
939
  ) -> Result<(), String> {
862
940
  use crate::compiler::manifest_builder::ManifestBuilder;
863
941
  use crate::compiler::component_tagger::ComponentTagger;
@@ -909,7 +987,7 @@ impl Compiler {
909
987
  // - Iterations are pre-rendered with initial array data
910
988
  // The manifest (written above) preserves BOTH branches so the runtime can
911
989
  // restore them and switch between branches reactively.
912
- let stamper = ValueStamper::new(&state, components_as_is)
990
+ let stamper = ValueStamper::with_constants(&state, components_as_is, global_constants)
913
991
  .map_err(|e| format!("Failed to create value stamper: {}", e))?;
914
992
  let stamped = stamper.stamp_html(html.to_string())
915
993
  .map_err(|e| format!("Failed to stamp HTML: {}", e))?;
@@ -920,6 +998,60 @@ impl Compiler {
920
998
  Ok(())
921
999
  }
922
1000
 
1001
+ /// Resolve the global `$` state keys that reassignment analysis proves are
1002
+ /// compile-time constants, mapped to their resolved values — the only globals
1003
+ /// safe to value-stamp into pre-rendered HTML. Scans every JS source under the
1004
+ /// project source root (`.js` modules, inline `<script>` bodies, and `on*`
1005
+ /// handler bodies), skipping the compiled output and the configured skips.
1006
+ /// Returns an empty map on any doubt (see reassignment_analyzer soundness).
1007
+ fn compute_global_constants(&self) -> Map<String, Value> {
1008
+ use crate::compiler::js_analyzer::JsAnalyzer;
1009
+ use crate::compiler::reassignment_analyzer::classify_constant_keys;
1010
+
1011
+ let root = &self.config.source;
1012
+ let output_canon = self.config.output.canonicalize().ok();
1013
+
1014
+ let mut sources: Vec<String> = Vec::new();
1015
+ let mut js_paths: Vec<PathBuf> = Vec::new();
1016
+ collect_state_sources(
1017
+ root,
1018
+ output_canon.as_deref(),
1019
+ &self.config.skip_files,
1020
+ &mut sources,
1021
+ &mut js_paths,
1022
+ );
1023
+
1024
+ // Resolve the global vibe()/state() initial state. Partial resolution
1025
+ // keeps statically-resolvable keys (e.g. `version: VERSION`) and drops
1026
+ // runtime-only ones (`settings: loadLocalStorage(...)`), which is exactly
1027
+ // the set we could ever stamp.
1028
+ let mut global_state: Map<String, Value> = Map::new();
1029
+ for path in &js_paths {
1030
+ let code = match fs::read_to_string(path) {
1031
+ Ok(c) => c,
1032
+ Err(_) => continue,
1033
+ };
1034
+ if !(code.contains("vibe(") || code.contains("state(")) {
1035
+ continue;
1036
+ }
1037
+ let mut analyzer = JsAnalyzer::new(root.clone());
1038
+ if let Some(Value::Object(state)) = analyzer.extract_state(&code) {
1039
+ for (key, value) in state {
1040
+ global_state.insert(key, value);
1041
+ }
1042
+ }
1043
+ }
1044
+ if global_state.is_empty() {
1045
+ return Map::new();
1046
+ }
1047
+
1048
+ let constant_keys = classify_constant_keys(&global_state, &sources);
1049
+ global_state
1050
+ .into_iter()
1051
+ .filter(|(key, _)| constant_keys.contains(key))
1052
+ .collect()
1053
+ }
1054
+
923
1055
  /// Generate manifests (called separately from main.rs if needed)
924
1056
  pub fn generate_manifests(&self) -> Result<ManifestStats, CompileError> {
925
1057
 
@@ -941,6 +1073,9 @@ impl Compiler {
941
1073
  let iterations_as_is = self.config.iterations_as_is;
942
1074
  let components_as_is = self.config.components_as_is;
943
1075
  let manifest_root = self.config.root.clone();
1076
+ // Resolve which global state keys are constant once, then share across all
1077
+ // pages (read-only; `&Map` is Sync so the parallel map can borrow it).
1078
+ let global_constants = self.compute_global_constants();
944
1079
 
945
1080
  let results: Vec<_> = html_files
946
1081
  .par_iter()
@@ -957,7 +1092,7 @@ impl Compiler {
957
1092
  };
958
1093
 
959
1094
  // Try to generate manifest for this file (skip on error)
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()) {
1095
+ 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(), &global_constants) {
961
1096
  Ok(()) => (true, None),
962
1097
  Err(e) => {
963
1098
  if verbose {
@@ -1969,20 +2104,28 @@ fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), CompileError> {
1969
2104
  /// Basic HTML minification
1970
2105
  fn minify_html(html: &str) -> String {
1971
2106
  let mut result = String::with_capacity(html.len());
1972
- let mut in_pre = false;
2107
+ let mut in_raw = false;
1973
2108
  let mut last_was_space = false;
1974
2109
 
1975
2110
  for line in html.lines() {
1976
2111
  let trimmed = line.trim();
1977
2112
 
1978
- if trimmed.contains("<pre") {
1979
- in_pre = true;
2113
+ // <pre>, <script> and <style> carry significant whitespace and must
2114
+ // survive minification verbatim: <pre> is literal text, while a JS
2115
+ // `//` line comment or ASI in <script> breaks the moment its trailing
2116
+ // newline is collapsed into a space (the comment swallows the rest of
2117
+ // the script). Keep these blocks line-for-line, exactly as <pre> always
2118
+ // did. The opening-tag line enters the block before we emit it; the
2119
+ // closing-tag line leaves it (and collapses, which only tightens the
2120
+ // bare `</pre>` / `</script>` / `</style>`).
2121
+ if trimmed.contains("<pre") || trimmed.contains("<script") || trimmed.contains("<style") {
2122
+ in_raw = true;
1980
2123
  }
1981
- if trimmed.contains("</pre>") {
1982
- in_pre = false;
2124
+ if trimmed.contains("</pre>") || trimmed.contains("</script>") || trimmed.contains("</style>") {
2125
+ in_raw = false;
1983
2126
  }
1984
2127
 
1985
- if in_pre {
2128
+ if in_raw {
1986
2129
  result.push_str(line);
1987
2130
  result.push('\n');
1988
2131
  last_was_space = false;
@@ -2028,3 +2171,50 @@ fn find_bytes_ci(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
2028
2171
  fn count_newlines(bytes: &[u8]) -> usize {
2029
2172
  bytes.iter().filter(|&&b| b == b'\n').count()
2030
2173
  }
2174
+
2175
+ #[cfg(test)]
2176
+ mod tests {
2177
+ use super::*;
2178
+
2179
+ #[test]
2180
+ fn minify_preserves_script_newlines_so_line_comments_dont_swallow_code() {
2181
+ // A `//` line comment inside a component script relies on its trailing
2182
+ // newline. If minify collapses newlines into spaces, the comment eats
2183
+ // the rest of the script → SyntaxError at runtime (new Function).
2184
+ let html = "<page>\n\
2185
+ <script type=\"module\">\n\
2186
+ \x20 const a = 1; // explain a\n\
2187
+ \x20 // keep explaining\n\
2188
+ \x20 const b = 2;\n\
2189
+ </script>\n\
2190
+ </page>";
2191
+
2192
+ let out = minify_html(html);
2193
+
2194
+ // The code after the comments must still be reachable, i.e. on its own
2195
+ // line rather than glued behind the `//`.
2196
+ let script = &out[out.find("<script").unwrap()..out.find("</script>").unwrap()];
2197
+ assert!(
2198
+ script.contains('\n'),
2199
+ "script newlines were collapsed, // comment swallows following code: {script:?}"
2200
+ );
2201
+ assert!(out.contains("const b = 2"), "code after // comment lost: {out:?}");
2202
+
2203
+ // Surrounding HTML must still be minified (tag boundaries tightened).
2204
+ assert!(out.contains("<page><script"), "non-script HTML not minified: {out:?}");
2205
+ }
2206
+
2207
+ #[test]
2208
+ fn minify_preserves_style_newlines() {
2209
+ let html = "<page>\n\
2210
+ <style>\n\
2211
+ \x20 a { color: red; }\n\
2212
+ \x20 b { color: blue; }\n\
2213
+ </style>\n\
2214
+ </page>";
2215
+
2216
+ let out = minify_html(html);
2217
+ let style = &out[out.find("<style").unwrap()..out.find("</style>").unwrap()];
2218
+ assert!(style.contains('\n'), "style newlines collapsed: {style:?}");
2219
+ }
2220
+ }