@ape-egg/vibe 2.1.4 → 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,24 @@
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
+
3
22
  ## [2.1.4] - 2026-06-18
4
23
 
5
24
  ### 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:
@@ -1599,7 +1599,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1599
1599
 
1600
1600
  [[package]]
1601
1601
  name = "vibe-compiler"
1602
- version = "1.9.1"
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.1"
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 {
@@ -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
- if let Ok(Value::Object(comp_state)) = StateExtractor::extract_from_html(
165
- &direct_scripts_html,
166
- &PathBuf::from(".")
167
- ) {
168
- // Only tag and register components that have state
169
- if !comp_state.is_empty() {
170
- let component_id = format!("_c{}", *counter.borrow());
171
- *counter.borrow_mut() += 1;
172
-
173
- // Add data-vibe-component-id attribute
174
- let mut attrs_mut = attrs.borrow_mut();
175
- attrs_mut.push(markup5ever::Attribute {
176
- name: QualName::new(
177
- None,
178
- Namespace::from(""),
179
- LocalName::from("data-vibe-component-id"),
180
- ),
181
- value: component_id.clone().into(),
182
- });
183
- drop(attrs_mut);
184
-
185
- // Rewrite this.property to componentId.property in the node's children
186
- // This allows ValueStamper to properly evaluate component-scoped expressions
187
- Self::rewrite_this_to_component_id(node, &component_id);
188
-
189
- component_states.borrow_mut().push((component_id, Value::Object(comp_state)));
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
- let html = r#"<!DOCTYPE html><html><body><component></component><component></component><component></component></body></html>"#;
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