@ape-egg/vibe 2.1.4 → 2.1.7
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 +25 -0
- package/README.md +48 -0
- 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 +137 -2
- package/compiler/src/compiler/component_tagger.rs +80 -28
- package/compiler/src/compiler/js_analyzer.rs +122 -36
- package/compiler/src/compiler/mod.rs +1 -0
- package/compiler/src/compiler/reassignment_analyzer.rs +456 -0
- package/compiler/src/compiler/value_stamper.rs +128 -16
- package/compiler/src/parser/html.rs +81 -3
- package/package.json +1 -1
- package/runtime/component.js +30 -3
- package/runtime/conditionals.js +8 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.1.7] - 2026-06-19
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **Compiled component-local state was lost when a conditional re-mounted** (`runtime/component.js`, `runtime/conditionals.js`) — on compiled pages each component's setup script is inlined as `type="vibe-module"`, but the boot-time pass only runs the scripts present at boot. A `<component>` carrying its own `component({...})` state inside a `<!-- if -->` rendered correctly the first time, then went blank after the conditional was toggled off and back on: the branch markup was restored but its `<!-- each _cN.x -->` read component-local state that had been released on unmount. `mountBranch` now runs the freshly mounted subtree's `vibe-module` scripts via a new `executeCompiledComponentScriptsIn(nodes)` — before nested iterations/conditionals render, so the re-registered state is in place when `<!-- each _cN.x -->` evaluates. The `_cN` id-counter advance and the script runner are factored out so the scoped and boot-time passes share one path. Repro: `e2e-runtime/conditional-component-remount.html`, `tests/e2e/components-in-conditionals.spec.js`.
|
|
8
|
+
|
|
9
|
+
## [2.1.6] - 2026-06-19
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **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).
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **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.
|
|
18
|
+
- **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`.
|
|
19
|
+
|
|
20
|
+
## [2.1.5] - 2026-06-19
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **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`.
|
|
25
|
+
- **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 (`>` → `>`, `&&` → `&&`). 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).
|
|
26
|
+
- **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.
|
|
27
|
+
|
|
3
28
|
## [2.1.4] - 2026-06-18
|
|
4
29
|
|
|
5
30
|
### Fixed
|
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:
|
|
Binary file
|
|
Binary file
|
package/compiler/src/Cargo.lock
CHANGED
package/compiler/src/Cargo.toml
CHANGED
|
@@ -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::
|
|
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 {
|
|
@@ -8,8 +8,17 @@ use serde_json::Value;
|
|
|
8
8
|
use std::collections::HashSet;
|
|
9
9
|
use std::path::PathBuf;
|
|
10
10
|
use std::cell::RefCell;
|
|
11
|
+
use std::sync::OnceLock;
|
|
11
12
|
use crate::compiler::state_extractor::StateExtractor;
|
|
12
13
|
|
|
14
|
+
/// Matches a `component(...)` state-registration call in a component's inline
|
|
15
|
+
/// script. The `\b` keeps it from firing on `subcomponent(`; resolvable or not,
|
|
16
|
+
/// its presence is what tells the tagger this wrapper owns component-local state.
|
|
17
|
+
fn component_call_regex() -> &'static Regex {
|
|
18
|
+
static RE: OnceLock<Regex> = OnceLock::new();
|
|
19
|
+
RE.get_or_init(|| Regex::new(r"\bcomponent\s*\(").unwrap())
|
|
20
|
+
}
|
|
21
|
+
|
|
13
22
|
pub struct ComponentTagger;
|
|
14
23
|
|
|
15
24
|
pub struct TaggedResult {
|
|
@@ -161,33 +170,50 @@ impl ComponentTagger {
|
|
|
161
170
|
}
|
|
162
171
|
}
|
|
163
172
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
)
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
173
|
+
// A component must be tagged whenever it REGISTERS local state via a
|
|
174
|
+
// `component(...)` call — that is what makes `this.X` in its markup
|
|
175
|
+
// resolvable at runtime. Whether the initial state VALUES are statically
|
|
176
|
+
// resolvable is a separate concern (it only governs FOUC value-stamping).
|
|
177
|
+
// A state built from a runtime expression — e.g.
|
|
178
|
+
// `component({ levels: Array.from({ length: 25 }, ...) })` — resolves to
|
|
179
|
+
// an empty object here, but the component still needs its id + `this.`
|
|
180
|
+
// rewrite, or a compiled `<!-- each this.levels -->` ships a raw `this.`
|
|
181
|
+
// the runtime can't resolve when a restored conditional branch re-parses
|
|
182
|
+
// the template (no wrapper-tag ancestor to fall back to).
|
|
183
|
+
let registers_state = component_call_regex().is_match(&direct_scripts_html);
|
|
184
|
+
|
|
185
|
+
if registers_state {
|
|
186
|
+
let comp_state = match StateExtractor::extract_from_html(
|
|
187
|
+
&direct_scripts_html,
|
|
188
|
+
&PathBuf::from(".")
|
|
189
|
+
) {
|
|
190
|
+
Ok(Value::Object(state)) => state,
|
|
191
|
+
_ => serde_json::Map::new(),
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
let component_id = format!("_c{}", *counter.borrow());
|
|
195
|
+
*counter.borrow_mut() += 1;
|
|
196
|
+
|
|
197
|
+
// Add data-vibe-component-id attribute
|
|
198
|
+
let mut attrs_mut = attrs.borrow_mut();
|
|
199
|
+
attrs_mut.push(markup5ever::Attribute {
|
|
200
|
+
name: QualName::new(
|
|
201
|
+
None,
|
|
202
|
+
Namespace::from(""),
|
|
203
|
+
LocalName::from("data-vibe-component-id"),
|
|
204
|
+
),
|
|
205
|
+
value: component_id.clone().into(),
|
|
206
|
+
});
|
|
207
|
+
drop(attrs_mut);
|
|
208
|
+
|
|
209
|
+
// Rewrite this.property to componentId.property in the node's children
|
|
210
|
+
// This allows ValueStamper to properly evaluate component-scoped expressions
|
|
211
|
+
Self::rewrite_this_to_component_id(node, &component_id);
|
|
212
|
+
|
|
213
|
+
// Register any statically-resolvable initial state under the id. May be
|
|
214
|
+
// empty (runtime-built state); the runtime fills it in when the inlined
|
|
215
|
+
// `component(...)` call runs on mount.
|
|
216
|
+
component_states.borrow_mut().push((component_id, Value::Object(comp_state)));
|
|
191
217
|
}
|
|
192
218
|
}
|
|
193
219
|
}
|
|
@@ -345,9 +371,35 @@ mod tests {
|
|
|
345
371
|
);
|
|
346
372
|
}
|
|
347
373
|
|
|
374
|
+
#[test]
|
|
375
|
+
fn tags_component_with_runtime_built_state() {
|
|
376
|
+
// AccountProgression shape: the ONLY state key is built from a runtime
|
|
377
|
+
// expression (`Array.from`), so static extraction resolves to an empty
|
|
378
|
+
// object. The wrapper must still be tagged and its `<!-- each this.X -->`
|
|
379
|
+
// rewritten — otherwise a restored conditional branch re-parses a raw
|
|
380
|
+
// `this.levels` the runtime can't resolve, and the loop renders nothing.
|
|
381
|
+
let html = r#"<!DOCTYPE html><html><body><component><script>component({ levels: Array.from({ length: 25 }, (_, i) => i + 1) });</script><slides><!-- each this.levels as lvl --><slide>@[lvl]</slide><!-- /each --></slides></component></body></html>"#;
|
|
382
|
+
|
|
383
|
+
let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
|
|
384
|
+
|
|
385
|
+
assert!(
|
|
386
|
+
result.html.contains("data-vibe-component-id=\"_c0\""),
|
|
387
|
+
"runtime-built-state component not tagged: {}",
|
|
388
|
+
result.html
|
|
389
|
+
);
|
|
390
|
+
assert!(
|
|
391
|
+
result.html.contains("each _c0.levels as lvl"),
|
|
392
|
+
"each comment not rewritten: {}",
|
|
393
|
+
result.html
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
348
397
|
#[test]
|
|
349
398
|
fn tag_multiple_components() {
|
|
350
|
-
|
|
399
|
+
// Each wrapper registers component-local state, so all three are tagged
|
|
400
|
+
// with sequential ids. (A bare `<component></component>` with no state
|
|
401
|
+
// call is intentionally NOT tagged — it owns no `this.` to resolve.)
|
|
402
|
+
let html = r#"<!DOCTYPE html><html><body><component><script>component({ a: 1 })</script></component><component><script>component({ b: 2 })</script></component><component><script>component({ c: 3 })</script></component></body></html>"#;
|
|
351
403
|
|
|
352
404
|
let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
|
|
353
405
|
|