@ape-egg/vibe 2.1.7 → 2.1.9
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 +13 -0
- package/ROADMAP.md +69 -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 +85 -56
- package/compiler/src/compiler/component_tagger.rs +78 -28
- package/compiler/src/compiler/watcher.rs +145 -5
- package/compiler/src/parser/html.rs +91 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.1.9] - 2026-06-20
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **Compiler 1.9.4 → 1.9.5 — array-literal each-root components broke when inlined** (`compiler/src/parser/html.rs`, `compile.rs`) — a component whose root is `<!-- each [prop] as a -->` receives its iterable as a prop and relies on the runtime's `__vibeiterprops` indirection: the runtime evaluates the prop binding in the *enclosing* loop scope, stashes the value in a global registry slot, and iterates that slot. Inlining instead baked the call-site's parent-loop alias straight into the each (`[card.signatureAbility]`); the runtime evaluates an array-literal iterable in global scope, where that alias is undefined, so the loop yielded zero items (the empty `AbilityCell` / status-chip bug in compiled mode). Such components (`is_iter_prop_root`) are now left as runtime `<component src>` tags instead of being inlined — in both the `src=` and custom-element inlining paths — and the `components/` directory is always mirrored to the output so the runtime can fetch their source, exactly as in non-compiled mode. Tests: `each_root_component_is_left_for_runtime`, `ordinary_component_still_inlines` (`html.rs`), and `tests/compiler/components`.
|
|
8
|
+
- **Compiler 1.9.4 → 1.9.5 — a stateful component nested inside another stole the inner's `this.` bindings** (`compiler/src/compiler/component_tagger.rs`) — the outer component's build-time `this.` → component-id rewrite descended through a nested component that registers its OWN `component({...})` state, claiming the inner's bindings and `if`/`each` directives with the outer id before the inner was reached. The inner's live state (registered under its own runtime id) then never reached its markup → empty each-loops and dead bindings (the `DebugContent` → `ScalingModal` empty-legend bug). The rewrite now stops at any nested state-registering component (`is_state_registering_component`); each component owns the `this.` inside it and gets its own id + rewrite when `walk_tag_and_extract` reaches it. Test: `nested_component_this_resolves_to_own_id`.
|
|
9
|
+
|
|
10
|
+
## [2.1.8] - 2026-06-19
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **Compiler 1.9.3 → 1.9.4 — watch-mode recompiles are incremental at the component-cache level** (`compiler/src/compiler/watcher.rs`, `compile.rs`) — on a component edit the watcher previously cleared the *entire* component cache, so every affected page re-expanded its whole component tree even though one leaf changed. It now invalidates only the edited component plus the components whose cached inlined content embeds it — its transitive inlining ancestors, via `DependencyGraph::get_all_dependent_components` — while every unrelated component stays cached and is reused. Supporting changes: the watcher adopts the initial full-compile's warm cache (`adopt_component_cache`) so even the first edit is incremental; cache keys are resolved source-relative (`component_cache_key`, matching the `<component src>` form, with paths outside the source root excluded so external-URL components are never wrongly invalidated); and incremental manifest generation (`generate_manifests_for_files`) now runs in parallel with rayon like the full build, since per-page static analysis dominates watch latency. A page-only edit invalidates no component caches at all. Unit tests cover the stale-ancestor set and cache-key normalization (`watcher.rs` tests).
|
|
15
|
+
|
|
3
16
|
## [2.1.7] - 2026-06-19
|
|
4
17
|
|
|
5
18
|
### Fixed
|
package/ROADMAP.md
CHANGED
|
@@ -323,6 +323,75 @@ Hand-roll the idle wait at the call site with `requestIdleCallback`. See `webdev
|
|
|
323
323
|
|
|
324
324
|
---
|
|
325
325
|
|
|
326
|
+
## Proposed: Surgical HMR in Compiled (Hyperspeed) Mode
|
|
327
|
+
|
|
328
|
+
**Status**: Proposal
|
|
329
|
+
**Priority**: High (developer experience)
|
|
330
|
+
**Category**: Compiler + Core Runtime (orchestrated by vite-plugin-vibe)
|
|
331
|
+
**Discovered**: 2026-06-19 (battle-brawlers `dev:compiled` loop — a one-line edit to a leaf component recompiles every page that inlines it)
|
|
332
|
+
|
|
333
|
+
### Problem
|
|
334
|
+
|
|
335
|
+
Runtime mode already has surgical component HMR: vite-plugin-vibe refetches the changed component's source, calls `$.renderComponent(...)` + `$.reconcile(...)`, and patches **only the live instances** of that component — no page-level work. State is preserved by reusing the component ids.
|
|
336
|
+
|
|
337
|
+
Compiled (hyperspeed) mode cannot do this today. The compiler **inlines components into each page's HTML and per-page manifest**, which *dissolves component identity*: the page manifest tree keeps the boundary as an anonymous positional node (`…→ component_1 → game-layout_0 → component_0 → …`) but nothing records *which source file produced it*. So the only invalidation the compiler can compute on a file save is the inverse graph — **component → dependent pages** — and it recompiles all of them.
|
|
338
|
+
|
|
339
|
+
Concrete cost: `AccountProgression.html` is included by `Sidebar` and `Overlay`, both of which live in `Layout`, which **every** page mounts. One leaf edit → all 27 pages + 27 manifests recompiled (~2.7s), versus runtime mode's ~instant single-boundary patch.
|
|
340
|
+
|
|
341
|
+
### Key realization — this is *not* "teach the compiler the runtime engine"
|
|
342
|
+
|
|
343
|
+
The instinct is that surgical HMR means giving the Rust compiler the engine's runtime awareness (component instantiation, props, slots, live DOM) just to know what to recompile. It does **not**. Two things are already in place:
|
|
344
|
+
|
|
345
|
+
1. **The runtime already ships the surgical engine** — `runtime/reconcile.js`, `runtime/component.js`, `runtime/component-cache.js`. Compiled pages boot the *same* runtime, so the patching machinery is already in the browser in compiled mode.
|
|
346
|
+
2. **The compiler already builds the static include-graph** ("Building dependency graph…") and already knows component identity *during* inlining — it simply discards it in the output.
|
|
347
|
+
|
|
348
|
+
What's missing is purely **emission + addressing**, not awareness:
|
|
349
|
+
|
|
350
|
+
- The compiler needs to **stamp each boundary with its `src`** and (Option A) **emit each component's compiled subtree as its own small unit** that pages *reference* rather than absorb.
|
|
351
|
+
- The runtime needs a **public boundary-swap API** built on the existing `reconcile` + `component-cache`, taking a recompiled unit + the live boundary and re-rendering just that subtree (live props/state stay the runtime's job — the compiler never needs them).
|
|
352
|
+
|
|
353
|
+
The compiler and the engine have *converged on the same structural model from opposite ends* (both understand `<component src>` and the component tree). Surgical HMR **connects** those two seams through the tooling layer; it does not merge them, and it does not give the compiler runtime DOM awareness. The compiler's job stays build-time-static (include graph + boundary metadata); the runtime's job stays runtime-dynamic (props, state, DOM).
|
|
354
|
+
|
|
355
|
+
### Layering constraint (non-negotiable)
|
|
356
|
+
|
|
357
|
+
`vite-plugin-vibe` is **sugar on top**. The dependency direction is one-way:
|
|
358
|
+
|
|
359
|
+
```
|
|
360
|
+
vite-plugin-vibe ──imports──▶ Vibe (compiler output + runtime public APIs)
|
|
361
|
+
Vibe ──never───▶ vite-plugin-vibe
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
- **Capabilities live in Vibe**, transport-agnostic:
|
|
365
|
+
- Compiler: emit boundary `src` identity; recompile a single component to its unit (`--watch` already incremental).
|
|
366
|
+
- Runtime: a public `swap(boundary, unit)` / `patchComponent(...)` API (reusing `reconcile`, `component-cache`, and component-id reuse for state preservation). Knows nothing about websockets or HMR.
|
|
367
|
+
- **Orchestration lives in the plugin**: watch the compiler's `--watch` output, carry the recompiled unit over the dev websocket, call the runtime's swap API. The plugin already sends a `vibe:component-update` event in runtime mode — the compiled path emits the *same* event with a compiled payload.
|
|
368
|
+
|
|
369
|
+
Neither the compiler nor the runtime gains a dependency on the plugin. The plugin remains optional sugar that delivers true HMR to **both** runtime dev and compiled dev.
|
|
370
|
+
|
|
371
|
+
### Two implementation paths
|
|
372
|
+
|
|
373
|
+
**Option A — per-component compiled units (full).**
|
|
374
|
+
Compiler tags `component_N` boundaries with `src` and modularizes manifests so each component's subtree is a separately-emittable, separately-loadable module; pages reference units instead of inlining them. Repurpose the dep graph from *component→pages* to *component→its unit*. An edit recompiles one small unit. Runtime swaps boundaries from the new unit. **Manifest modularization is the bulk of the work** (today a page manifest is one ~440KB blob).
|
|
375
|
+
|
|
376
|
+
**Option B — hybrid, recommended first (small lift).**
|
|
377
|
+
In dev only, the compiler **keeps the boundary markers + the call-site props/slot** in the compiled output instead of fully dissolving them. On a component edit, the plugin treats *just that boundary* like a **runtime** component — refetch source → `renderComponent` + `reconcile` that one subtree — and leaves the rest of the page compiled. This reuses vite-plugin-vibe's existing runtime-HMR path almost verbatim; the **only** compiler change is "don't throw away boundary + props/slot metadata in a dev compiled build." Gets ~90% of the benefit; the edited boundary is re-expanded by the runtime engine rather than from a compiled unit (fine for dev).
|
|
378
|
+
|
|
379
|
+
### Trade-offs
|
|
380
|
+
|
|
381
|
+
- **Dev/prod divergence.** Inlining *is* hyperspeed; a boundary-preserving dev build is structurally different from the inlined prod build, so compiled-specific bugs (cross-boundary manifest merging, minified inlined scripts) may not reproduce in surgical-dev. Every framework lives with this (dev HMR builds ≠ prod). Keep **both** modes — surgical dev for iteration, full-inline compile for fidelity passes — and pick per task.
|
|
382
|
+
- **Only internal edits go surgical.** Changing a *call site* (props/slot a page passes) still recompiles that page — same as runtime HMR re-mounting on a prop change.
|
|
383
|
+
|
|
384
|
+
### Related (precedents — all do exactly this shape)
|
|
385
|
+
|
|
386
|
+
- **Svelte** (`svelte-hmr` / Vite plugin): each component compiles to a module with an HMR proxy; a changed component hot-swaps its instances, preserving state where it can.
|
|
387
|
+
- **Vue SFC HMR**: compiler stamps `__hmrId`; runtime `rerender`/`reload` patches just that component's instances.
|
|
388
|
+
- **React Fast Refresh**: components compile with register/signature metadata + module boundaries; the refresh runtime re-renders only affected components, preserving hook state.
|
|
389
|
+
- **Vite HMR boundaries**: `import.meta.hot.accept` — module-scoped patching instead of full reload.
|
|
390
|
+
|
|
391
|
+
All three compile components into **independently-addressable, hot-swappable units with HMR boundaries**, then patch via a runtime accept API. This proposal is the same pattern adapted to Vibe's manifest model, with the orchestration deliberately kept in the (one-way-dependent) plugin.
|
|
392
|
+
|
|
393
|
+
---
|
|
394
|
+
|
|
326
395
|
## Future Proposals
|
|
327
396
|
|
|
328
397
|
*This section reserved for additional feature proposals*
|
|
Binary file
|
|
Binary file
|
package/compiler/src/Cargo.lock
CHANGED
package/compiler/src/Cargo.toml
CHANGED
|
@@ -558,9 +558,29 @@ impl Compiler {
|
|
|
558
558
|
}
|
|
559
559
|
}
|
|
560
560
|
|
|
561
|
-
///
|
|
562
|
-
|
|
563
|
-
|
|
561
|
+
/// Drop only the named cache entries, forcing those components to be
|
|
562
|
+
/// re-read + re-inlined on the next compile while every other component is
|
|
563
|
+
/// reused from cache. `keys` are normalized component srcs (e.g.
|
|
564
|
+
/// `/components/Sidebar.html`). Returns how many entries were actually
|
|
565
|
+
/// removed. This is the incremental-watch path: invalidate the edited
|
|
566
|
+
/// component and its inlining ancestors, nothing else.
|
|
567
|
+
pub fn invalidate_components(&mut self, keys: &std::collections::HashSet<String>) -> usize {
|
|
568
|
+
let before = self.component_cache.len();
|
|
569
|
+
self.component_cache.retain(|key, _| !keys.contains(key));
|
|
570
|
+
before - self.component_cache.len()
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/// Number of components currently held in the inlining cache.
|
|
574
|
+
pub fn cached_component_count(&self) -> usize {
|
|
575
|
+
self.component_cache.len()
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/// Take over another compiler's warm component cache, leaving it empty.
|
|
579
|
+
/// The watch loop uses this to carry the initial full-compile cache into the
|
|
580
|
+
/// incremental compiler, so even the first edit reuses everything unchanged
|
|
581
|
+
/// instead of re-fetching the whole tree.
|
|
582
|
+
pub fn adopt_component_cache(&mut self, other: &mut Compiler) {
|
|
583
|
+
self.component_cache = std::mem::take(&mut other.component_cache);
|
|
564
584
|
}
|
|
565
585
|
|
|
566
586
|
pub fn compile(&mut self) -> Result<CompileStats, CompileError> {
|
|
@@ -864,56 +884,63 @@ impl Compiler {
|
|
|
864
884
|
/// Generate manifests for specific HTML files (incremental compilation for watch mode)
|
|
865
885
|
pub fn generate_manifests_for_files(&self, files: &[PathBuf]) -> Result<ManifestStats, CompileError> {
|
|
866
886
|
let start = Instant::now();
|
|
867
|
-
|
|
868
|
-
|
|
887
|
+
|
|
888
|
+
// Mirror generate_manifests: process each page's manifest in parallel.
|
|
889
|
+
// Manifest generation is the bulk of an incremental recompile (static
|
|
890
|
+
// analysis + serialization per page), so a sequential loop over the
|
|
891
|
+
// affected pages left most cores idle and dominated watch latency.
|
|
892
|
+
let output_dir = self.config.output.clone();
|
|
893
|
+
let source_root = self.config.source.clone();
|
|
894
|
+
let verbose = self.verbose;
|
|
895
|
+
let iterations_as_is = self.config.iterations_as_is;
|
|
896
|
+
let components_as_is = self.config.components_as_is;
|
|
897
|
+
let manifest_root = self.config.root.clone();
|
|
898
|
+
// Resolve constant global-state keys once, shared read-only across pages.
|
|
869
899
|
let global_constants = self.compute_global_constants();
|
|
870
900
|
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
.strip_prefix(&
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
901
|
+
let results: Vec<bool> = files
|
|
902
|
+
.par_iter()
|
|
903
|
+
.map(|file_path| {
|
|
904
|
+
let relative_path = match file_path.strip_prefix(&source_root).unwrap_or(file_path).to_str() {
|
|
905
|
+
Some(r) => r,
|
|
906
|
+
None => return false,
|
|
907
|
+
};
|
|
878
908
|
|
|
879
|
-
|
|
909
|
+
let output_path = output_dir.join(relative_path);
|
|
910
|
+
if !output_path.exists() {
|
|
911
|
+
return false;
|
|
912
|
+
}
|
|
880
913
|
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
914
|
+
let html = match fs::read_to_string(&output_path) {
|
|
915
|
+
Ok(h) => h,
|
|
916
|
+
Err(_) => return false,
|
|
917
|
+
};
|
|
885
918
|
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
&global_constants,
|
|
905
|
-
) {
|
|
906
|
-
Ok(()) => {
|
|
907
|
-
pages_processed += 1;
|
|
908
|
-
}
|
|
909
|
-
Err(e) => {
|
|
910
|
-
pages_skipped += 1;
|
|
911
|
-
if self.verbose {
|
|
912
|
-
println!(" Skipped ({}): {}", e, relative_path);
|
|
919
|
+
match Self::generate_file_manifest(
|
|
920
|
+
&html,
|
|
921
|
+
&output_path,
|
|
922
|
+
&output_dir,
|
|
923
|
+
relative_path,
|
|
924
|
+
verbose,
|
|
925
|
+
iterations_as_is,
|
|
926
|
+
components_as_is,
|
|
927
|
+
&source_root,
|
|
928
|
+
manifest_root.as_deref(),
|
|
929
|
+
&global_constants,
|
|
930
|
+
) {
|
|
931
|
+
Ok(()) => true,
|
|
932
|
+
Err(e) => {
|
|
933
|
+
if verbose {
|
|
934
|
+
eprintln!(" Skipped ({}): {}", e, relative_path);
|
|
935
|
+
}
|
|
936
|
+
false
|
|
913
937
|
}
|
|
914
938
|
}
|
|
915
|
-
}
|
|
916
|
-
|
|
939
|
+
})
|
|
940
|
+
.collect();
|
|
941
|
+
|
|
942
|
+
let pages_processed = results.iter().filter(|&&ok| ok).count();
|
|
943
|
+
let pages_skipped = results.len() - pages_processed;
|
|
917
944
|
|
|
918
945
|
let total_time_ms = start.elapsed().as_millis() as f64;
|
|
919
946
|
|
|
@@ -1243,17 +1270,19 @@ impl Compiler {
|
|
|
1243
1270
|
continue;
|
|
1244
1271
|
}
|
|
1245
1272
|
|
|
1246
|
-
//
|
|
1273
|
+
// The components directory is always mirrored to the output. With
|
|
1274
|
+
// components_as_is every component is loaded at runtime; otherwise
|
|
1275
|
+
// most are inlined at build time, but iter-prop each-root components
|
|
1276
|
+
// are deliberately left as runtime `<component src>` tags (see
|
|
1277
|
+
// is_iter_prop_root in parser/html.rs) and the runtime fetches their
|
|
1278
|
+
// source from here — exactly as it does in non-compiled mode.
|
|
1247
1279
|
if file_name == self.config.components {
|
|
1248
|
-
if
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
};
|
|
1255
|
-
self.copy_directory(&path, &new_relative, canonical_source, stats)?;
|
|
1256
|
-
}
|
|
1280
|
+
let new_relative = if relative_path.is_empty() {
|
|
1281
|
+
file_name.to_string()
|
|
1282
|
+
} else {
|
|
1283
|
+
format!("{}/{}", relative_path, file_name)
|
|
1284
|
+
};
|
|
1285
|
+
self.copy_directory(&path, &new_relative, canonical_source, stats)?;
|
|
1257
1286
|
// Skip further processing (don't recurse into components)
|
|
1258
1287
|
continue;
|
|
1259
1288
|
}
|
|
@@ -142,33 +142,7 @@ impl ComponentTagger {
|
|
|
142
142
|
// treated as a stateful component with the merged state of all its descendants.
|
|
143
143
|
// That made rewrite_this_to_component_id rewrite every @[this.xxx] in the page
|
|
144
144
|
// to @[_c0.xxx] before child components could claim their own bindings.
|
|
145
|
-
let
|
|
146
|
-
for child in node.children.borrow().iter() {
|
|
147
|
-
if let NodeData::Element { name: ref child_name, .. } = child.data {
|
|
148
|
-
if child_name.local.as_ref() == "script" {
|
|
149
|
-
// Read the script's raw text directly. Serializing the
|
|
150
|
-
// element would HTML-escape JS operators (`>` -> `>`,
|
|
151
|
-
// `&&` -> `&&`) because the serializer loses the
|
|
152
|
-
// rawtext context when it starts at the script's children;
|
|
153
|
-
// the escaped source then fails to parse, dropping a
|
|
154
|
-
// `component(stateVar)` component to the regex fallback
|
|
155
|
-
// (which only matches `component({` literals) and leaving
|
|
156
|
-
// it untagged.
|
|
157
|
-
let mut script_text = String::new();
|
|
158
|
-
for grandchild in child.children.borrow().iter() {
|
|
159
|
-
if let NodeData::Text { ref contents } = grandchild.data {
|
|
160
|
-
script_text.push_str(&contents.borrow());
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
// Wrap in <script> so extract_from_html routes it through
|
|
164
|
-
// the JS AST analyzer, which resolves `component(stateVar)`
|
|
165
|
-
// via its declaration (the regex fallback can't).
|
|
166
|
-
direct_scripts_html.push_str("<script>");
|
|
167
|
-
direct_scripts_html.push_str(&script_text);
|
|
168
|
-
direct_scripts_html.push_str("</script>");
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
145
|
+
let direct_scripts_html = Self::direct_scripts_html(node);
|
|
172
146
|
|
|
173
147
|
// A component must be tagged whenever it REGISTERS local state via a
|
|
174
148
|
// `component(...)` call — that is what makes `this.X` in its markup
|
|
@@ -237,6 +211,53 @@ impl ComponentTagger {
|
|
|
237
211
|
})
|
|
238
212
|
}
|
|
239
213
|
|
|
214
|
+
/// Concatenate a node's DIRECT `<script>` children as `<script>…</script>`.
|
|
215
|
+
/// Reads raw text (not serialized) so JS operators (`>`, `&&`) aren't
|
|
216
|
+
/// HTML-escaped — the escaped source would fail the AST parse and drop a
|
|
217
|
+
/// `component(stateVar)` call to the regex fallback. Used both to extract a
|
|
218
|
+
/// wrapper's own state and to detect nested component boundaries.
|
|
219
|
+
fn direct_scripts_html(node: &Handle) -> String {
|
|
220
|
+
let mut out = String::new();
|
|
221
|
+
for child in node.children.borrow().iter() {
|
|
222
|
+
if let NodeData::Element { name: ref child_name, .. } = child.data {
|
|
223
|
+
if child_name.local.as_ref() == "script" {
|
|
224
|
+
let mut script_text = String::new();
|
|
225
|
+
for grandchild in child.children.borrow().iter() {
|
|
226
|
+
if let NodeData::Text { ref contents } = grandchild.data {
|
|
227
|
+
script_text.push_str(&contents.borrow());
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
out.push_str("<script>");
|
|
231
|
+
out.push_str(&script_text);
|
|
232
|
+
out.push_str("</script>");
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
out
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/// Is this node a component wrapper that registers its OWN local state? Such
|
|
240
|
+
/// a node gets its own `_cN` id and `this.`→id pass when `walk_tag_and_extract`
|
|
241
|
+
/// reaches it, so an ancestor's rewrite must stop here — descending would let
|
|
242
|
+
/// the ancestor claim the nested component's bindings with the wrong id (the
|
|
243
|
+
/// DebugContent→ScalingModal empty-legend bug).
|
|
244
|
+
fn is_state_registering_component(node: &Handle) -> bool {
|
|
245
|
+
if let NodeData::Element { name, attrs, .. } = &node.data {
|
|
246
|
+
let tag_name = name.local.as_ref();
|
|
247
|
+
let borrowed = attrs.borrow();
|
|
248
|
+
let is_component = tag_name == "component" && !Self::has_src_attr(&borrowed);
|
|
249
|
+
let is_div_component = tag_name == "div"
|
|
250
|
+
&& Self::has_class_component(&borrowed)
|
|
251
|
+
&& !Self::has_src_attr(&borrowed);
|
|
252
|
+
if !(is_component || is_div_component) {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
drop(borrowed);
|
|
256
|
+
return component_call_regex().is_match(&Self::direct_scripts_html(node));
|
|
257
|
+
}
|
|
258
|
+
false
|
|
259
|
+
}
|
|
260
|
+
|
|
240
261
|
/// Rewrite `this.X` to `componentId.X` throughout a component's subtree:
|
|
241
262
|
/// inside `@[...]` bindings (text + attributes, nested paths and multi-ref
|
|
242
263
|
/// expressions) and inside if/each/else-if directive comments. Resolving the
|
|
@@ -312,8 +333,14 @@ impl ComponentTagger {
|
|
|
312
333
|
}
|
|
313
334
|
}
|
|
314
335
|
|
|
315
|
-
// Recurse into children
|
|
336
|
+
// Recurse into children — but STOP at a nested component that registers
|
|
337
|
+
// its own state. It owns the `this.` inside it and gets its own id +
|
|
338
|
+
// rewrite when walk_tag_and_extract reaches it; descending here would
|
|
339
|
+
// claim its bindings with this ancestor's id (the empty-legend bug).
|
|
316
340
|
for child in node.children.borrow().iter() {
|
|
341
|
+
if Self::is_state_registering_component(child) {
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
317
344
|
Self::rewrite_node_recursive(child, binding_regex, this_prop, state_this_prop, component_id);
|
|
318
345
|
}
|
|
319
346
|
}
|
|
@@ -394,6 +421,29 @@ mod tests {
|
|
|
394
421
|
);
|
|
395
422
|
}
|
|
396
423
|
|
|
424
|
+
#[test]
|
|
425
|
+
fn nested_component_this_resolves_to_own_id() {
|
|
426
|
+
// DebugContent → ScalingModal shape: a stateful component nested inside
|
|
427
|
+
// another stateful component, each owning its own `this.`. The outer's
|
|
428
|
+
// this.→id rewrite must STOP at the inner component boundary — otherwise
|
|
429
|
+
// it claims the inner's bindings/directives with the OUTER id before the
|
|
430
|
+
// inner is reached, and the inner's live state (registered under its own
|
|
431
|
+
// runtime id) never reaches the markup → empty each-loops, dead bindings.
|
|
432
|
+
let html = r#"<!DOCTYPE html><html><body><component><script>component({ outer: 1 })</script><outer-mark>@[this.outer]</outer-mark><component><script>component({ inner: 2, items: [] })</script><modal-root open="@[this.open]"><!-- each this.items as it --><inner-mark>@[this.inner]</inner-mark><!-- /each --></modal-root></component></component></body></html>"#;
|
|
433
|
+
|
|
434
|
+
let result = ComponentTagger::tag_components(html, &PathBuf::from(".")).unwrap();
|
|
435
|
+
|
|
436
|
+
// Outer is _c0 (visited first), inner is _c1 (reached on recursion).
|
|
437
|
+
assert!(result.html.contains("@[_c0.outer]"), "outer binding wrong: {}", result.html);
|
|
438
|
+
assert!(result.html.contains("@[_c1.inner]"), "inner binding not rewritten to own id: {}", result.html);
|
|
439
|
+
assert!(result.html.contains("each _c1.items as it"), "inner each not rewritten to own id: {}", result.html);
|
|
440
|
+
assert!(result.html.contains("open=\"@[_c1.open]\""), "inner attr binding not rewritten to own id: {}", result.html);
|
|
441
|
+
// The outer must NOT have claimed any of the inner's bindings/directives.
|
|
442
|
+
assert!(!result.html.contains("@[_c0.inner]"), "outer claimed inner text binding: {}", result.html);
|
|
443
|
+
assert!(!result.html.contains("each _c0.items"), "outer claimed inner each: {}", result.html);
|
|
444
|
+
assert!(!result.html.contains("@[_c0.open]"), "outer claimed inner attr binding: {}", result.html);
|
|
445
|
+
}
|
|
446
|
+
|
|
397
447
|
#[test]
|
|
398
448
|
fn tag_multiple_components() {
|
|
399
449
|
// Each wrapper registers component-local state, so all three are tagged
|
|
@@ -81,6 +81,30 @@ impl DependencyGraph {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
/// Get the edited component itself plus every component that transitively
|
|
86
|
+
/// inlines it. These are exactly the component caches that go stale on an
|
|
87
|
+
/// edit: a parent's cached content is the *fully inlined* child, so when the
|
|
88
|
+
/// child changes the parent's cache is stale too. Unrelated components keep
|
|
89
|
+
/// their cache. The returned set always includes `component` itself.
|
|
90
|
+
pub fn get_all_dependent_components(&self, component: &Path) -> HashSet<PathBuf> {
|
|
91
|
+
let mut all = HashSet::new();
|
|
92
|
+
self.collect_dependent_components(component, &mut all);
|
|
93
|
+
all
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
fn collect_dependent_components(&self, component: &Path, all: &mut HashSet<PathBuf>) {
|
|
97
|
+
// insert() == false → already seen: doubles as the cycle guard.
|
|
98
|
+
if !all.insert(component.to_path_buf()) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if let Some(users) = self.component_to_components.get(component) {
|
|
103
|
+
for user in users {
|
|
104
|
+
self.collect_dependent_components(user, all);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
84
108
|
}
|
|
85
109
|
|
|
86
110
|
/// Extract component references from HTML content
|
|
@@ -126,6 +150,19 @@ fn to_kebab_case(s: &str) -> String {
|
|
|
126
150
|
result
|
|
127
151
|
}
|
|
128
152
|
|
|
153
|
+
/// Map a component's source path to the normalized key under which its inlined
|
|
154
|
+
/// content is cached (e.g. `/components/Sidebar.html`). The cache key is the
|
|
155
|
+
/// component's path relative to the source root, with a leading slash — the same
|
|
156
|
+
/// form `<component src>` resolves to. Returns None for paths outside the source
|
|
157
|
+
/// root (e.g. external URL components, which never go stale on a local edit).
|
|
158
|
+
fn component_cache_key(component: &Path, canonical_source: &Path) -> Option<String> {
|
|
159
|
+
component
|
|
160
|
+
.strip_prefix(canonical_source)
|
|
161
|
+
.ok()
|
|
162
|
+
.and_then(|rel| rel.to_str())
|
|
163
|
+
.map(|rel| format!("/{}", rel.replace('\\', "/")))
|
|
164
|
+
}
|
|
165
|
+
|
|
129
166
|
/// Check if a path should be blacklisted based on SKIP_FILES patterns
|
|
130
167
|
/// This checks both the filename and all path components relative to source root
|
|
131
168
|
fn is_path_blacklisted(path: &Path, source_root: &Path, skip_files: &[String]) -> bool {
|
|
@@ -326,9 +363,16 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
|
|
|
326
363
|
// Canonicalize output path for reliable comparison
|
|
327
364
|
let canonical_output = config.output.canonicalize()
|
|
328
365
|
.unwrap_or_else(|_| config.output.clone());
|
|
366
|
+
// Canonical source root: dependency-graph paths are canonical, so map them to
|
|
367
|
+
// cache keys against the same base.
|
|
368
|
+
let canonical_source = config.source.canonicalize()
|
|
369
|
+
.unwrap_or_else(|_| config.source.clone());
|
|
329
370
|
|
|
330
371
|
// Keep compiler and parser alive to reuse component cache across incremental compilations
|
|
331
372
|
let mut watch_compiler = Compiler::new(config.clone(), false);
|
|
373
|
+
// Carry the initial compile's warm component cache into the watcher so the
|
|
374
|
+
// first edit is already incremental (only the edited subtree re-expands).
|
|
375
|
+
watch_compiler.adopt_component_cache(&mut compiler);
|
|
332
376
|
let mut parser = {
|
|
333
377
|
use crate::parser::HtmlParser;
|
|
334
378
|
let mut p = HtmlParser::new(config.components_path());
|
|
@@ -373,6 +417,10 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
|
|
|
373
417
|
|
|
374
418
|
// Determine what needs recompiling
|
|
375
419
|
let mut pages_to_recompile: HashSet<PathBuf> = HashSet::new();
|
|
420
|
+
// Component caches that go stale this batch: each edited
|
|
421
|
+
// component plus the ancestors that inline it. Everything
|
|
422
|
+
// else stays cached and is reused.
|
|
423
|
+
let mut stale_component_keys: HashSet<String> = HashSet::new();
|
|
376
424
|
|
|
377
425
|
for path in &changed_paths {
|
|
378
426
|
// Skip files in output directory (avoid infinite loop)
|
|
@@ -398,8 +446,16 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
|
|
|
398
446
|
if !dependent_pages.is_empty() {
|
|
399
447
|
println!("{} {} changed", "[watch]".cyan(), relative_path.display());
|
|
400
448
|
|
|
401
|
-
// Invalidate
|
|
402
|
-
|
|
449
|
+
// Invalidate only the edited component and the
|
|
450
|
+
// components whose cached inlined content embeds it
|
|
451
|
+
// (its ancestors). Every other component stays cached,
|
|
452
|
+
// so each affected page re-expands just the changed
|
|
453
|
+
// subtree instead of its whole component tree.
|
|
454
|
+
for stale in graph.get_all_dependent_components(&path_canonical) {
|
|
455
|
+
if let Some(key) = component_cache_key(&stale, &canonical_source) {
|
|
456
|
+
stale_component_keys.insert(key);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
403
459
|
|
|
404
460
|
// Reload the changed component in the parser's element cache
|
|
405
461
|
// (used for custom element syntax like <Layout>)
|
|
@@ -546,9 +602,19 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
|
|
|
546
602
|
|
|
547
603
|
// Compile HTML files
|
|
548
604
|
if !html_files.is_empty() {
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
|
|
605
|
+
// Drop only the stale component caches (edited components +
|
|
606
|
+
// their inlining ancestors); unchanged components are reused.
|
|
607
|
+
// A page-only edit invalidates nothing here — its components
|
|
608
|
+
// are still valid — so the whole cache is reused as-is.
|
|
609
|
+
let invalidated = watch_compiler.invalidate_components(&stale_component_keys);
|
|
610
|
+
if invalidated > 0 {
|
|
611
|
+
println!("{} {} component{} re-expanded, {} reused from cache",
|
|
612
|
+
"↻".cyan(),
|
|
613
|
+
invalidated,
|
|
614
|
+
if invalidated == 1 { "" } else { "s" },
|
|
615
|
+
watch_compiler.cached_component_count(),
|
|
616
|
+
);
|
|
617
|
+
}
|
|
552
618
|
|
|
553
619
|
match watch_compiler.compile_specific_html_files(&html_files, &parser) {
|
|
554
620
|
Ok(stats) => {
|
|
@@ -645,3 +711,77 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
|
|
|
645
711
|
|
|
646
712
|
Ok(())
|
|
647
713
|
}
|
|
714
|
+
|
|
715
|
+
#[cfg(test)]
|
|
716
|
+
mod tests {
|
|
717
|
+
use super::*;
|
|
718
|
+
|
|
719
|
+
// leaf <- mid <- layout <- page, with `sibling` an unrelated component that
|
|
720
|
+
// layout also uses. Editing `leaf` invalidates the cached, fully-inlined
|
|
721
|
+
// content of leaf and every component whose cache embeds it (mid, layout) —
|
|
722
|
+
// but NOT `sibling` (its cache never contained leaf) and NOT pages (they are
|
|
723
|
+
// recompiled, not cache entries).
|
|
724
|
+
fn chain_graph() -> (DependencyGraph, [PathBuf; 5]) {
|
|
725
|
+
let leaf = PathBuf::from("/src/components/AccountProgression.html");
|
|
726
|
+
let mid = PathBuf::from("/src/components/Sidebar.html");
|
|
727
|
+
let layout = PathBuf::from("/src/components/Layout.html");
|
|
728
|
+
let sibling = PathBuf::from("/src/components/Topbar.html");
|
|
729
|
+
let page = PathBuf::from("/src/pages/index.html");
|
|
730
|
+
|
|
731
|
+
let mut graph = DependencyGraph::new();
|
|
732
|
+
graph.add_dependency(mid.clone(), leaf.clone(), true); // mid uses leaf
|
|
733
|
+
graph.add_dependency(layout.clone(), mid.clone(), true); // layout uses mid
|
|
734
|
+
graph.add_dependency(layout.clone(), sibling.clone(), true); // layout uses sibling
|
|
735
|
+
graph.add_dependency(page.clone(), layout.clone(), false); // page uses layout
|
|
736
|
+
|
|
737
|
+
(graph, [leaf, mid, layout, sibling, page])
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
#[test]
|
|
741
|
+
fn dependent_components_are_just_the_stale_ancestor_chain() {
|
|
742
|
+
let (graph, [leaf, mid, layout, sibling, page]) = chain_graph();
|
|
743
|
+
|
|
744
|
+
let stale = graph.get_all_dependent_components(&leaf);
|
|
745
|
+
|
|
746
|
+
// The edited component itself, plus every component that inlines it.
|
|
747
|
+
assert!(stale.contains(&leaf), "leaf itself must be invalidated");
|
|
748
|
+
assert!(stale.contains(&mid), "mid inlines leaf — stale");
|
|
749
|
+
assert!(stale.contains(&layout), "layout transitively inlines leaf — stale");
|
|
750
|
+
// A sibling that does not contain leaf keeps its cache.
|
|
751
|
+
assert!(!stale.contains(&sibling), "sibling never inlined leaf — must stay cached");
|
|
752
|
+
// Pages are recompiled, not component-cache entries.
|
|
753
|
+
assert!(!stale.contains(&page), "pages are not in the component set");
|
|
754
|
+
assert_eq!(stale.len(), 3, "exactly leaf + mid + layout go stale");
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
#[test]
|
|
758
|
+
fn editing_a_leaf_invalidates_far_fewer_than_the_dependent_pages() {
|
|
759
|
+
let (graph, [leaf, _mid, _layout, _sibling, _page]) = chain_graph();
|
|
760
|
+
|
|
761
|
+
// dependent-pages drives which output files recompile; dependent-
|
|
762
|
+
// components drives which caches to drop — the minimal stale set, which
|
|
763
|
+
// never includes unrelated siblings.
|
|
764
|
+
let stale_components = graph.get_all_dependent_components(&leaf);
|
|
765
|
+
let dependent_pages = graph.get_all_dependent_pages(&leaf);
|
|
766
|
+
|
|
767
|
+
assert_eq!(dependent_pages.len(), 1, "one page inlines leaf");
|
|
768
|
+
assert!(stale_components.iter().all(|c| c.starts_with("/src/components")));
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
#[test]
|
|
772
|
+
fn cache_key_is_source_relative_with_leading_slash() {
|
|
773
|
+
let source = PathBuf::from("/proj/src");
|
|
774
|
+
// Must match the key form fetch_component_recursive stores.
|
|
775
|
+
assert_eq!(
|
|
776
|
+
component_cache_key(&PathBuf::from("/proj/src/components/Sidebar.html"), &source)
|
|
777
|
+
.as_deref(),
|
|
778
|
+
Some("/components/Sidebar.html"),
|
|
779
|
+
);
|
|
780
|
+
// A path outside the source root (e.g. an external URL component's
|
|
781
|
+
// resolved path) yields no key, so it's never wrongly invalidated.
|
|
782
|
+
assert_eq!(
|
|
783
|
+
component_cache_key(&PathBuf::from("/elsewhere/x.html"), &source),
|
|
784
|
+
None,
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
@@ -367,6 +367,12 @@ impl HtmlParser {
|
|
|
367
367
|
Some((open_tag.start(), close_end, props, slot_content))
|
|
368
368
|
}).collect();
|
|
369
369
|
|
|
370
|
+
// Iter-prop components stay as runtime `<component src>` tags (see
|
|
371
|
+
// is_iter_prop_root). The content is the same for every match, so skip all.
|
|
372
|
+
if is_iter_prop_root(component_content) {
|
|
373
|
+
return result;
|
|
374
|
+
}
|
|
375
|
+
|
|
370
376
|
// Replace from end to start (sorted descending by start position)
|
|
371
377
|
let mut sorted = matches;
|
|
372
378
|
sorted.sort_by(|a, b| b.0.cmp(&a.0));
|
|
@@ -449,6 +455,12 @@ impl HtmlParser {
|
|
|
449
455
|
};
|
|
450
456
|
|
|
451
457
|
if let Some(template) = external_cache.get(&normalized_src) {
|
|
458
|
+
// Iter-prop components stay as runtime `<component src>` tags so the
|
|
459
|
+
// runtime renders them via its __vibeiterprops path — inlining
|
|
460
|
+
// breaks the enclosing loop's scope (see is_iter_prop_root).
|
|
461
|
+
if is_iter_prop_root(template) {
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
452
464
|
let mut replacement = substitute_props(template, &props);
|
|
453
465
|
replacement = neuter_component_scripts(&replacement);
|
|
454
466
|
|
|
@@ -578,6 +590,30 @@ fn substitute_state_ref(body: &str, name: &str, replacement: &str) -> String {
|
|
|
578
590
|
/// runtime's renderPropsAndSlot (runtime/component.js). Both sides must
|
|
579
591
|
/// transform identically:
|
|
580
592
|
/// - exact `@[propName]` bindings (case-insensitive)
|
|
593
|
+
/// Whether a component template's ROOT is an array-literal each
|
|
594
|
+
/// (`<!-- each [expr] as a -->`). Such a component receives its iterable as a prop
|
|
595
|
+
/// and depends on the runtime's `__vibeiterprops` indirection: the runtime
|
|
596
|
+
/// evaluates the prop binding in the *enclosing* loop scope, stores the value in a
|
|
597
|
+
/// global registry slot, and rewrites the each to iterate that slot. Inlining
|
|
598
|
+
/// instead bakes the call-site's parent-loop alias straight into the each
|
|
599
|
+
/// (`[card.signatureAbility]`); the runtime evaluates an array-literal iterable in
|
|
600
|
+
/// GLOBAL scope (that's the registry-slot pattern), where the alias is undefined →
|
|
601
|
+
/// the loop yields zero items (the empty AbilityCell / status-chip bug in compiled
|
|
602
|
+
/// mode). So such components are NOT inlined — they stay as runtime
|
|
603
|
+
/// `<component src>` tags, and the compiler ships their source so the runtime can
|
|
604
|
+
/// fetch and instantiate them exactly as it does in non-compiled mode.
|
|
605
|
+
fn is_iter_prop_root(template: &str) -> bool {
|
|
606
|
+
let t = template.trim_start();
|
|
607
|
+
t.strip_prefix("<!--")
|
|
608
|
+
.map(str::trim_start)
|
|
609
|
+
.and_then(|r| r.strip_prefix("each"))
|
|
610
|
+
.map(str::trim_start)
|
|
611
|
+
.is_some_and(|after| after.starts_with('['))
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/// Substitute component props into a template.
|
|
615
|
+
///
|
|
616
|
+
/// Rewrites:
|
|
581
617
|
/// - prop identifiers inside other `@[expr]` bindings
|
|
582
618
|
/// - prop identifiers inside directive comments (`if` / `else if` / `each`)
|
|
583
619
|
/// - `$.propName` references inside event-handler attribute bodies
|
|
@@ -853,4 +889,59 @@ mod tests {
|
|
|
853
889
|
// A literal prop after the `>=` prop still substitutes → tag parsed fully.
|
|
854
890
|
assert!(out.contains(r#"n="7""#), "count not substituted: {out}");
|
|
855
891
|
}
|
|
892
|
+
|
|
893
|
+
#[test]
|
|
894
|
+
fn each_root_component_is_left_for_runtime() {
|
|
895
|
+
// A component whose ROOT is an array-literal each (`<!-- each [prop] as a -->`)
|
|
896
|
+
// depends on the runtime's __vibeiterprops indirection (the runtime evaluates
|
|
897
|
+
// the prop in the enclosing loop scope and stashes it in a global registry
|
|
898
|
+
// slot the each iterates). Inlining bakes the parent-loop alias into the each
|
|
899
|
+
// (`[card.signatureAbility]`); the runtime evaluates array-literal iterables
|
|
900
|
+
// in GLOBAL scope, where that alias is undefined → zero items (the empty
|
|
901
|
+
// AbilityCell / status-chip bug). So such a component must NOT be inlined:
|
|
902
|
+
// leave the `<component src=...>` tag for the runtime to fetch and instantiate.
|
|
903
|
+
let parser = HtmlParser::new(std::path::PathBuf::from("."));
|
|
904
|
+
let page = concat!(
|
|
905
|
+
r#"<!-- each cards as card --><card-section>"#,
|
|
906
|
+
r#"<component src="/components/AbilityCell.html" ability="@[card.signatureAbility]"></component>"#,
|
|
907
|
+
r#"</card-section><!-- /each -->"#,
|
|
908
|
+
);
|
|
909
|
+
// AbilityCell's root is an each over the `[ability]` array-literal prop.
|
|
910
|
+
let template = r#"<!-- each [ability] as a --><ability-tile><icon @[a.icon]></icon></ability-tile><!-- /each -->"#;
|
|
911
|
+
let mut cache = HashMap::new();
|
|
912
|
+
cache.insert("/components/AbilityCell.html".to_string(), template.to_string());
|
|
913
|
+
|
|
914
|
+
let out = parser.inline_component_elements(page, &cache);
|
|
915
|
+
|
|
916
|
+
// Left un-inlined for the runtime, with its prop binding intact…
|
|
917
|
+
assert!(
|
|
918
|
+
out.contains(r#"<component src="/components/AbilityCell.html""#),
|
|
919
|
+
"each-root component must be left un-inlined for the runtime: {out}"
|
|
920
|
+
);
|
|
921
|
+
assert!(
|
|
922
|
+
out.contains(r#"ability="@[card.signatureAbility]""#),
|
|
923
|
+
"prop binding lost on un-inlined component: {out}"
|
|
924
|
+
);
|
|
925
|
+
// …and the parent-loop alias must NOT have been baked into an each iterable.
|
|
926
|
+
assert!(
|
|
927
|
+
!out.contains("each [card.signatureAbility]"),
|
|
928
|
+
"parent-loop alias was inlined into the component each (the bug): {out}"
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
#[test]
|
|
933
|
+
fn ordinary_component_still_inlines() {
|
|
934
|
+
// Guard: a normal component (root is NOT an array-literal each) must still
|
|
935
|
+
// inline as before — the skip is scoped to the iter-prop pattern only.
|
|
936
|
+
let parser = HtmlParser::new(std::path::PathBuf::from("."));
|
|
937
|
+
let page = r#"<page><component src="/components/Badge.html" label="@[title]"></component></page>"#;
|
|
938
|
+
let template = r#"<badge-pill>@[label]</badge-pill>"#;
|
|
939
|
+
let mut cache = HashMap::new();
|
|
940
|
+
cache.insert("/components/Badge.html".to_string(), template.to_string());
|
|
941
|
+
|
|
942
|
+
let out = parser.inline_component_elements(page, &cache);
|
|
943
|
+
|
|
944
|
+
assert!(out.contains("<badge-pill>"), "ordinary component should inline: {out}");
|
|
945
|
+
assert!(!out.contains(r#"src="/components/Badge.html""#), "ordinary component src should be gone: {out}");
|
|
946
|
+
}
|
|
856
947
|
}
|