@ape-egg/vibe 1.6.1 → 1.7.0

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,48 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.7.0] - 2026-02-19
4
+
5
+ ### Fixed
6
+
7
+ - **Watch mode stale component cache** - Incremental recompilation no longer uses stale component content
8
+ - Component cache is cleared before every recompile trigger, ensuring components are always re-read from disk
9
+ - Component cache and parser element cache are also invalidated immediately when a component file changes
10
+ - Fixes corrupted output (old event handlers, wrong attributes) that appeared on every watch-mode save after the first
11
+
12
+ - **Custom element transform — nested hyphenated tags** (`elementsAsIs: false`)
13
+ - `<accordion-content>` inside `<accordion>` previously produced `<div class="accordion" -content>` due to the `<accordion>` regex partially matching the longer tag name
14
+ - Fixed by requiring whitespace or end-of-tag immediately after the tag name in the opening tag regex
15
+ - Tags are now also sorted by descending length so more specific names (e.g. `accordion-content`) are always processed before their prefixes (`accordion`)
16
+
17
+ - **Custom element transform — existing class preserved** (`elementsAsIs: false`)
18
+ - `<crow class="i-should-preserve">` previously compiled to `<div class="crow">`, discarding the original class
19
+ - Existing `class="..."` is now merged: result is `<div class="crow i-should-preserve">`
20
+
21
+ - **`elementsAsIs` config respected in dependency scanning**
22
+ - `fetch_components_for_files` and `fetch_all_components` were hardcoded to `elements_as_is: false`, ignoring the project config
23
+ - Both functions now correctly read `self.config.elements_as_is` and `self.config.reserved_elements`
24
+
25
+ - **`vibe-dehydrate` protected during manifest stamping**
26
+ - Content inside `<template vibe-dehydrate>` was incorrectly processed during value stamping
27
+ - Dehydrated regions are now extracted before processing and restored afterwards
28
+
29
+ - **Conditional evaluation errors handled gracefully**
30
+ - A failed `<!-- if ... -->` expression (undefined variable, syntax error) now defaults to `false` instead of crashing
31
+
32
+ ### Changed
33
+
34
+ - **Lifecycle event API** — `vibe()` now returns an object that supports `.on(event, callback)` before boot completes
35
+ - `$.on('ready', cb)` — fires once after all components load and initial processing completes
36
+ - `$.on('afterUpdate', cb)` — fires on every state change
37
+ - `$.on('afterDomMutation', cb)` — fires after every DOM mutation batch
38
+ - Listeners registered before boot are queued and replayed once the runtime is ready
39
+
40
+ - **Component `onComplete` timing** — deferred via `queueMicrotask` to give user code a chance to register listeners before the ready callback fires
41
+
42
+ - **Iteration performance** — `cloneTreeNode` avoids spread operator; `nameBindings` now correctly carried through cloned iteration trees
43
+
44
+ ---
45
+
3
46
  ## [1.6.1] - 2026-02-12
4
47
 
5
48
  ### Fixed
@@ -540,7 +583,7 @@
540
583
  - **Iteration**: `<!-- each items as item, i -->` with efficient diffing
541
584
  - **Nested iteration**: `<!-- each category.items as item -->`
542
585
  - **Conditionals**: `<!-- if condition -->...<!-- else -->...<!-- /if -->`
543
- - **Dehydrate**: `<div dehydrate>` to skip reactive processing
586
+ - **Dehydrate**: `<div vibe-dehydrate>` to skip reactive processing
544
587
  - **Expression evaluation**: `@[count * 2]`, `@[firstName + ' ' + lastName]`
545
588
 
546
589
  ### Performance
package/README.md CHANGED
@@ -23,9 +23,9 @@ The core reactive runtime. Works directly in the browser without any build tools
23
23
  <head>
24
24
  <link rel="stylesheet" href="./node_modules/@ape-egg/vibe/vibe.css" />
25
25
  <script type="module">
26
- import state from './node_modules/@ape-egg/vibe/runtime/index.js';
27
- // Attaches to element with attribute "vibe" by default
28
- window.$ = state({ name: 'World', count: 0 });
26
+ import vibe from './node_modules/@ape-egg/vibe/index.js';
27
+
28
+ vibe({ name: 'World', count: 0 });
29
29
  </script>
30
30
  </head>
31
31
  <body vibe-fouc>
@@ -123,7 +123,7 @@ Runtime component loading with props and slots:
123
123
  Skip reactive processing for an element:
124
124
 
125
125
  ```html
126
- <code dehydrate>@[this] displays literally</code>
126
+ <code vibe-dehydrate>@[this] displays literally</code>
127
127
  ```
128
128
 
129
129
  ### Reserved Words & Gotchas
@@ -133,28 +133,35 @@ Vibe uses specific patterns and keywords that have special meaning. Avoid using
133
133
  #### Classes & Attributes
134
134
 
135
135
  - **`vibe-fouc`** — Class or attribute for FOUC (Flash of Unstyled Content) prevention. Automatically removed after hydration completes.
136
+
136
137
  ```html
137
- <body vibe-fouc> <!-- or class="vibe-fouc" -->
138
+ <body vibe-fouc>
139
+ <!-- or class="vibe-fouc" -->
140
+ </body>
138
141
  ```
139
142
 
140
143
  - **`vibe-dehydrate`** — Class or attribute to skip reactive processing. Useful for displaying literal `@[...]` syntax in documentation.
141
144
  ```html
142
- <code vibe-dehydrate>@[variable]</code> <!-- or class="vibe-dehydrate" -->
145
+ <code vibe-dehydrate>@[variable]</code>
146
+ <!-- or class="vibe-dehydrate" -->
143
147
  ```
144
148
 
145
149
  #### Element Names & Classes
146
150
 
147
151
  - **`<component>`** — Element name for component system. Used with `src` attribute for runtime component loading, or as a wrapper for inlined components.
152
+
148
153
  ```html
149
154
  <component src="/path/to/component.html"></component>
150
155
  ```
151
156
 
152
157
  - **`class="component"`** — Alternative syntax for components using standard HTML elements. Useful for HTML validation or accessibility.
158
+
153
159
  ```html
154
160
  <div class="component" src="/path/to/component.html"></div>
155
161
  ```
156
162
 
157
163
  - **`<slot>`** — Element name for component content injection. Gets replaced with content passed between component tags.
164
+
158
165
  ```html
159
166
  <!-- In component file -->
160
167
  <slot></slot>
@@ -168,6 +175,7 @@ Vibe uses specific patterns and keywords that have special meaning. Avoid using
168
175
  #### Comment Syntax
169
176
 
170
177
  - **`<!-- each -->`** / **`<!-- /each -->`** — Iteration block markers.
178
+
171
179
  ```html
172
180
  <!-- each items as item -->
173
181
  <!-- /each -->
@@ -190,9 +198,10 @@ Vibe uses specific patterns and keywords that have special meaning. Avoid using
190
198
  #### Global Properties
191
199
 
192
200
  - **`window.$`** — Global reactive state object. All reactive data should be accessed through this.
201
+
193
202
  ```javascript
194
203
  window.$ = state({ count: 0 });
195
- $.count++; // Triggers reactive updates
204
+ $.count++; // Triggers reactive updates
196
205
  ```
197
206
 
198
207
  - **`window.__vibeManifest`** — Internal manifest data. Used by the compiler for optimization. Don't modify.
@@ -208,13 +217,14 @@ Vibe uses specific patterns and keywords that have special meaning. Avoid using
208
217
  - **`vibe:ready`** — Custom event fired when Vibe completes initial hydration.
209
218
  ```javascript
210
219
  document.addEventListener('vibe:ready', () => {
211
- console.log('Vibe is ready');
220
+ console.info('Vibe is ready');
212
221
  });
213
222
  ```
214
223
 
215
224
  #### Special Attribute Meanings
216
225
 
217
226
  - **`src`** on **`<component>`** or **`<div class="component">`** — Triggers runtime component fetching. Components without `src` are treated as inline wrappers.
227
+
218
228
  ```html
219
229
  <!-- Both work the same way -->
220
230
  <component src="/components/card.html"></component>
@@ -385,6 +395,7 @@ bunx vibe compile --watch
385
395
  ```
386
396
 
387
397
  Features:
398
+
388
399
  - **Incremental builds** — Only recompiles changed files (~100ms)
389
400
  - **Dependency tracking** — Changes to components trigger recompilation of pages using them
390
401
  - **Debounced** — 300ms debounce prevents excessive compilation during rapid changes
package/boot.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // Used by both index.js (global state) and component.js (component state)
3
3
 
4
4
  import main from './runtime/index.js';
5
+ import { getPendingListeners } from './index.js';
5
6
 
6
7
  let bootQueued = false;
7
8
  let booted = false;
@@ -39,6 +40,16 @@ export const boot = () => {
39
40
  // Boot with merged state
40
41
  window.$ = main(mergedState, config, targetSelector);
41
42
 
43
+ // Apply pending listeners from vibe instance
44
+ const pendingListeners = getPendingListeners();
45
+ if (pendingListeners) {
46
+ Object.keys(pendingListeners).forEach(event => {
47
+ pendingListeners[event].forEach(callback => {
48
+ window.$.on(event, callback);
49
+ });
50
+ });
51
+ }
52
+
42
53
  return window.$;
43
54
  };
44
55
 
@@ -2061,7 +2061,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
2061
2061
 
2062
2062
  [[package]]
2063
2063
  name = "vibe-compiler"
2064
- version = "1.6.1"
2064
+ version = "1.7.0"
2065
2065
  dependencies = [
2066
2066
  "clap",
2067
2067
  "colored",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vibe-compiler"
3
- version = "1.6.1"
3
+ version = "1.7.0"
4
4
  edition = "2021"
5
5
  description = "Vibe framework compiler - compiles Vibe source files into optimized output"
6
6
  authors = ["Kim Korte"]
@@ -40,9 +40,8 @@ const MIRROR_EXTENSIONS: &[&str] = &[
40
40
 
41
41
  // Files and directories to skip when walking source (supports glob patterns)
42
42
  // Note: Output directory is checked dynamically (not hardcoded here)
43
+ // Note: Dotfiles are handled by starts_with('.') check in should_skip_path()
43
44
  pub const SKIP_FILES: &[&str] = &[
44
- ".git",
45
- ".claude",
46
45
  "target", // Rust build artifacts
47
46
  "tests", // Test files
48
47
  "test-results",
@@ -56,6 +55,11 @@ pub const SKIP_FILES: &[&str] = &[
56
55
 
57
56
  /// Check if a path should be skipped based on SKIP_FILES patterns
58
57
  pub fn should_skip_path(path: &Path, name: &str) -> bool {
58
+ // Check if name starts with dot (dotfiles/directories)
59
+ if name.starts_with('.') {
60
+ return true;
61
+ }
62
+
59
63
  // Check exact name match (for directories and simple filenames)
60
64
  if SKIP_FILES.contains(&name) {
61
65
  return true;
@@ -433,6 +437,11 @@ impl Compiler {
433
437
  }
434
438
  }
435
439
 
440
+ /// Clear all cached component content (forces re-fetch on next compile)
441
+ pub fn clear_component_cache(&mut self) {
442
+ self.component_cache.clear();
443
+ }
444
+
436
445
  pub fn compile(&mut self) -> Result<CompileStats, CompileError> {
437
446
  let mut stats = CompileStats {
438
447
  files_compiled: 0,
@@ -492,6 +501,19 @@ impl Compiler {
492
501
  }
493
502
  }
494
503
 
504
+ // Clean output directory if not in no-clean mode
505
+ if !self.config.no_clean && self.config.output.exists() {
506
+ if self.verbose {
507
+ println!(" Cleaning output directory: {}", self.config.output.display());
508
+ }
509
+ fs::remove_dir_all(&self.config.output).map_err(|e| {
510
+ CompileError::WriteError {
511
+ path: self.config.output.display().to_string(),
512
+ source: e,
513
+ }
514
+ })?;
515
+ }
516
+
495
517
  // Create output directory
496
518
  if !self.config.output.exists() {
497
519
  fs::create_dir_all(&self.config.output).map_err(|_| {
@@ -800,10 +822,9 @@ impl Compiler {
800
822
  // Stamp values (pre-render) AFTER building manifest
801
823
  // When components_as_is is true, skip stamping inside component elements (runtime will handle them)
802
824
  let stamper = ValueStamper::new(&state, components_as_is)?;
803
- let mut pre_rendered = stamper.stamp_html(html.to_string())?;
825
+ let pre_rendered = stamper.stamp_html(html.to_string())?;
804
826
 
805
- // Remove FOUC prevention since HTML is pre-rendered
806
- pre_rendered = remove_fouc_prevention(pre_rendered);
827
+ // Note: FOUC removal happens in compile_html_file(), not here
807
828
 
808
829
  // Write pre-rendered HTML
809
830
  fs::write(html_path, pre_rendered)
@@ -1099,12 +1120,17 @@ impl Compiler {
1099
1120
  );
1100
1121
 
1101
1122
  // Minify if requested
1102
- let output = if self.config.minify {
1123
+ let mut output = if self.config.minify {
1103
1124
  minify_html(&processed)
1104
1125
  } else {
1105
1126
  processed
1106
1127
  };
1107
1128
 
1129
+ // Remove FOUC prevention unless fouc_as_is is enabled
1130
+ if !self.config.fouc_as_is {
1131
+ output = remove_fouc_prevention(output);
1132
+ }
1133
+
1108
1134
  // Write to output
1109
1135
  let output_path = self.get_output_path(path, relative_path)?;
1110
1136
  fs::write(&output_path, output).map_err(|e| CompileError::WriteError {
@@ -1212,8 +1238,8 @@ impl Compiler {
1212
1238
  // Transform custom tags to <component> tags
1213
1239
  let transformed = parser.process_html(
1214
1240
  &content,
1215
- false, // elements_as_is
1216
- &[], // reserved_elements
1241
+ self.config.elements_as_is,
1242
+ &self.config.reserved_elements,
1217
1243
  true, // components_as_is (don't inline, just transform)
1218
1244
  &self.config.components,
1219
1245
  );
@@ -1249,8 +1275,8 @@ impl Compiler {
1249
1275
  // Transform custom tags to <component> tags
1250
1276
  let transformed = parser.process_html(
1251
1277
  &content,
1252
- false, // elements_as_is
1253
- &[], // reserved_elements
1278
+ self.config.elements_as_is,
1279
+ &self.config.reserved_elements,
1254
1280
  true, // components_as_is (don't inline, just transform)
1255
1281
  &self.config.components,
1256
1282
  );
@@ -1463,8 +1489,8 @@ impl Compiler {
1463
1489
  let file_name = entry.file_name();
1464
1490
  let file_name_str = file_name.to_string_lossy();
1465
1491
 
1466
- // Skip hidden files and specific directories/patterns
1467
- if file_name_str.starts_with('.') || should_skip_path(&path, &file_name_str) {
1492
+ // Skip specific directories/patterns (includes dotfiles via SKIP_FILES)
1493
+ if should_skip_path(&path, &file_name_str) {
1468
1494
  continue;
1469
1495
  }
1470
1496
 
@@ -20,10 +20,23 @@ pub struct TaggedResult {
20
20
  impl ComponentTagger {
21
21
  /// Find component wrappers, add deterministic IDs, and structure state
22
22
  pub fn tag_components(html: &str, base_path: &PathBuf) -> Result<TaggedResult, String> {
23
- // Parse HTML
23
+ // Extract ALL <template> content to protect it from HTML parser
24
+ // HTML parsers can strip content from <template> tags during serialization
25
+ let template_regex = regex::Regex::new(r"(?s)<template[^>]*>.*?</template>").unwrap();
26
+ let mut template_placeholders: Vec<String> = Vec::new();
27
+ let mut html_with_placeholders = html.to_string();
28
+
29
+ for (i, mat) in template_regex.find_iter(html).enumerate() {
30
+ let content = mat.as_str();
31
+ let placeholder = format!("<!--VIBE_TEMPLATE_PLACEHOLDER_{}-->", i);
32
+ template_placeholders.push(content.to_string());
33
+ html_with_placeholders = html_with_placeholders.replace(content, &placeholder);
34
+ }
35
+
36
+ // Parse HTML (with placeholders instead of actual vibe-dehydrate content)
24
37
  let dom = parse_document(RcDom::default(), Default::default())
25
38
  .from_utf8()
26
- .read_from(&mut html.as_bytes())
39
+ .read_from(&mut html_with_placeholders.as_bytes())
27
40
  .map_err(|e| format!("Failed to parse HTML: {:?}", e))?;
28
41
 
29
42
  // Find all component wrappers and extract their state
@@ -39,9 +52,15 @@ impl ComponentTagger {
39
52
  SerializeOpts::default()
40
53
  ).map_err(|e| format!("Failed to serialize HTML: {:?}", e))?;
41
54
 
42
- let modified_html = String::from_utf8(modified_html_bytes)
55
+ let mut modified_html = String::from_utf8(modified_html_bytes)
43
56
  .map_err(|e| format!("Failed to convert HTML to UTF-8: {}", e))?;
44
57
 
58
+ // Restore template content from placeholders
59
+ for (i, content) in template_placeholders.iter().enumerate() {
60
+ let placeholder = format!("<!--VIBE_TEMPLATE_PLACEHOLDER_{}-->", i);
61
+ modified_html = modified_html.replace(&placeholder, content);
62
+ }
63
+
45
64
  // Extract all state from HTML
46
65
  let all_state = StateExtractor::extract_from_html(html, base_path)?;
47
66
 
@@ -79,8 +79,21 @@ impl<'a> ValueStamper<'a> {
79
79
  }
80
80
 
81
81
  pub fn stamp_html(&self, html: String) -> Result<String, String> {
82
+ // Extract vibe-dehydrate content to protect it from processing
83
+ // This prevents @[...] markers and iterations/conditionals from being processed
84
+ let dehydrate_regex = Regex::new(r"(?s)<template[^>]*\svibe-dehydrate[^>]*>.*?</template>").unwrap();
85
+ let mut dehydrate_placeholders: Vec<String> = Vec::new();
86
+ let mut html_with_placeholders = html.clone();
87
+
88
+ for (i, mat) in dehydrate_regex.find_iter(&html).enumerate() {
89
+ let content = mat.as_str();
90
+ let placeholder = format!("<!--VIBE_DEHYDRATE_PLACEHOLDER_{}-->", i);
91
+ dehydrate_placeholders.push(content.to_string());
92
+ html_with_placeholders = html_with_placeholders.replace(content, &placeholder);
93
+ }
94
+
82
95
  // First, render iterations (expands templates into multiple instances)
83
- let html = self.render_iterations(html)?;
96
+ let html = self.render_iterations(html_with_placeholders)?;
84
97
 
85
98
  // Then, render conditionals (resolve if/else branches)
86
99
  let html = self.render_conditionals(html)?;
@@ -95,6 +108,13 @@ impl<'a> ValueStamper<'a> {
95
108
  }
96
109
  }
97
110
 
111
+ // Skip elements with vibe-dehydrate attribute (content should not be processed)
112
+ // This is a secondary protection in case placeholders aren't used
113
+ let dehydrate_regex = Regex::new(r"(?s)<template[^>]*\svibe-dehydrate[^>]*>.*?</template>").unwrap();
114
+ for mat in dehydrate_regex.find_iter(&html) {
115
+ skip_regions.push((mat.start(), mat.end()));
116
+ }
117
+
98
118
  // When components_as_is is true, skip component elements (runtime will handle them)
99
119
  // Match both <component> and <div class="component"> elements
100
120
  if self.components_as_is {
@@ -148,6 +168,15 @@ impl<'a> ValueStamper<'a> {
148
168
  // Add remaining text
149
169
  result.push_str(&html[last_pos..]);
150
170
 
171
+ // Clean up attributes that still contain unresolved markers
172
+ result = self.cleanup_unresolved_attributes(result);
173
+
174
+ // Restore vibe-dehydrate content from placeholders
175
+ for (i, content) in dehydrate_placeholders.iter().enumerate() {
176
+ let placeholder = format!("<!--VIBE_DEHYDRATE_PLACEHOLDER_{}-->", i);
177
+ result = result.replace(&placeholder, content);
178
+ }
179
+
151
180
  // Finally, clean up boolean-like attributes with falsy values
152
181
  // When components_as_is is true, skip cleanup inside component elements
153
182
  Ok(self.cleanup_boolean_attributes(result, &skip_regions))
@@ -238,13 +267,19 @@ impl<'a> ValueStamper<'a> {
238
267
  // Render nested iterations first
239
268
  let mut item_html = temp_stamper.render_iterations_recursive(template.clone())?;
240
269
 
241
- // Then stamp all bindings in this item's context
270
+ // Then render conditionals with this item's context
271
+ item_html = temp_stamper.render_conditionals(item_html)?;
272
+
273
+ // Finally stamp all bindings in this item's context
242
274
  item_html = temp_stamper.binding_regex.replace_all(&item_html, |caps: &Captures| {
243
275
  let expr = &caps[1];
244
276
  temp_stamper.eval_expression(expr)
245
277
  .unwrap_or_else(|| caps[0].to_string())
246
278
  }).to_string();
247
279
 
280
+ // Clean up attributes with unresolved markers (e.g., data-tutorial="@[item.id]" when item.id is undefined)
281
+ item_html = temp_stamper.cleanup_unresolved_attributes(item_html);
282
+
248
283
  rendered_items.push(item_html);
249
284
  }
250
285
 
@@ -500,9 +535,31 @@ impl<'a> ValueStamper<'a> {
500
535
  fn try_eval_condition(&self, condition: &str) -> Option<bool> {
501
536
  self.context.with(|ctx| -> Option<bool> {
502
537
  // Try to evaluate condition
503
- // If it fails (e.g., references undefined iteration variables), return None
504
- let result: rquickjs::Value = ctx.eval(condition).ok()?;
505
- result.as_bool()
538
+ let result: rquickjs::Value = match ctx.eval(condition) {
539
+ Ok(val) => val,
540
+ Err(_) => {
541
+ // Evaluation failed (undefined variable, syntax error, etc.)
542
+ // Default to false as per requirements
543
+ return Some(false);
544
+ }
545
+ };
546
+
547
+ // Use JavaScript truthiness rules instead of requiring boolean type
548
+ // This allows conditions like "item.id" to work correctly
549
+ if result.is_bool() {
550
+ result.as_bool()
551
+ } else if result.is_null() || result.is_undefined() {
552
+ Some(false)
553
+ } else if result.is_number() {
554
+ // 0, NaN are falsy
555
+ result.as_number().map(|n| n != 0.0 && !n.is_nan())
556
+ } else if result.is_string() {
557
+ // Empty string is falsy
558
+ result.as_string().and_then(|s| s.to_string().ok()).map(|s| !s.is_empty())
559
+ } else {
560
+ // Objects, arrays are truthy
561
+ Some(true)
562
+ }
506
563
  })
507
564
  }
508
565
 
@@ -510,6 +567,19 @@ impl<'a> ValueStamper<'a> {
510
567
  /// Boolean-like attributes (not in VALUE_ATTRS) should be:
511
568
  /// - Removed entirely when falsy
512
569
  /// - Present with empty value when truthy (HTML5 boolean attribute syntax)
570
+ /// Remove attributes that still contain unresolved markers (e.g., data-tutorial="@[item.id]")
571
+ /// These occur when iterating over items where some don't have the referenced property
572
+ fn cleanup_unresolved_attributes(&self, html: String) -> String {
573
+ // Match attributes with marker values: attr="@[...]"
574
+ // Use non-greedy match and look for closing "]" at end of attribute value
575
+ let attr_with_marker = Regex::new(r#"\s+([\w-]+)="@\[.+?\]""#).unwrap();
576
+
577
+ attr_with_marker.replace_all(&html, |_caps: &Captures| {
578
+ // Remove the entire attribute when it has an unresolved marker
579
+ String::new()
580
+ }).to_string()
581
+ }
582
+
513
583
  fn cleanup_boolean_attributes(&self, html: String, skip_regions: &[(usize, usize)]) -> String {
514
584
  // Create a set for faster lookup
515
585
  let value_attrs: HashSet<&str> = VALUE_ATTRS.iter().copied().collect();
@@ -328,7 +328,7 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
328
328
 
329
329
  // Keep compiler and parser alive to reuse component cache across incremental compilations
330
330
  let mut watch_compiler = Compiler::new(config.clone(), false);
331
- let parser = {
331
+ let mut parser = {
332
332
  use crate::parser::HtmlParser;
333
333
  let mut p = HtmlParser::new(config.components_path());
334
334
  if let Err(e) = p.load_elements() {
@@ -396,10 +396,52 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
396
396
  let dependent_pages = graph.get_all_dependent_pages(&path_canonical);
397
397
  if !dependent_pages.is_empty() {
398
398
  println!("{} {} changed", "[watch]".cyan(), relative_path.display());
399
+
400
+ // Invalidate component cache so stale content isn't used
401
+ watch_compiler.clear_component_cache();
402
+
403
+ // Reload the changed component in the parser's element cache
404
+ // (used for custom element syntax like <Layout>)
405
+ if path.exists() {
406
+ let _ = parser.reload_element(path);
407
+ }
408
+
399
409
  pages_to_recompile.extend(dependent_pages);
400
410
  }
401
411
  } else if let Some(ext) = path.extension() {
402
412
  if ext == "html" {
413
+ // Check if file still exists (handle deletions)
414
+ if !path.exists() {
415
+ // File was deleted - clean up dependency graph
416
+ println!("{} {} deleted", "[watch]".yellow(), relative_path.display());
417
+
418
+ if let Some(old_deps) = graph.page_to_components.remove(path) {
419
+ for dep in old_deps {
420
+ if let Some(pages) = graph.component_to_pages.get_mut(&dep) {
421
+ pages.remove(path);
422
+ }
423
+ }
424
+ }
425
+
426
+ // Delete corresponding compiled output
427
+ let output_path = canonical_output.join(relative_path);
428
+ if output_path.exists() {
429
+ if let Err(e) = std::fs::remove_file(&output_path) {
430
+ eprintln!("{}: Failed to delete {}: {}", "Warning".yellow(), output_path.display(), e);
431
+ }
432
+ }
433
+
434
+ // Delete corresponding manifest
435
+ let manifest_path = canonical_output.join("vibe-hyperspeed").join(format!("{}.manifest.js", relative_path.display()));
436
+ if manifest_path.exists() {
437
+ if let Err(e) = std::fs::remove_file(&manifest_path) {
438
+ eprintln!("{}: Failed to delete manifest {}: {}", "Warning".yellow(), manifest_path.display(), e);
439
+ }
440
+ }
441
+
442
+ continue;
443
+ }
444
+
403
445
  // Page changed - recompile just this page
404
446
  println!("{} {} changed", "[watch]".cyan(), relative_path.display());
405
447
  pages_to_recompile.insert(path.clone());
@@ -431,7 +473,10 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
431
473
  }
432
474
 
433
475
  // Separate HTML files (from dependency graph) and CSS/JS assets (from changed_paths)
434
- let html_files: Vec<PathBuf> = pages_to_recompile.into_iter().collect();
476
+ // Filter out deleted files
477
+ let html_files: Vec<PathBuf> = pages_to_recompile.into_iter()
478
+ .filter(|p| p.exists())
479
+ .collect();
435
480
 
436
481
  let mut asset_files: Vec<PathBuf> = Vec::new();
437
482
  for path in &changed_paths {
@@ -446,6 +491,24 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
446
491
  continue;
447
492
  }
448
493
 
494
+ // Handle deleted asset files
495
+ if !path.exists() {
496
+ if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
497
+ if ext == "css" || ext == "js" {
498
+ let relative_path = path.strip_prefix(&config.source).unwrap_or(path);
499
+ let output_path = canonical_output.join(relative_path);
500
+ if output_path.exists() {
501
+ if let Err(e) = std::fs::remove_file(&output_path) {
502
+ eprintln!("{}: Failed to delete {}: {}", "Warning".yellow(), output_path.display(), e);
503
+ } else {
504
+ println!("{} {} deleted", "[watch]".yellow(), relative_path.display());
505
+ }
506
+ }
507
+ }
508
+ }
509
+ continue;
510
+ }
511
+
449
512
  if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
450
513
  if ext == "css" || ext == "js" {
451
514
  asset_files.push(path.clone());
@@ -482,6 +545,10 @@ pub fn watch(config: Config, verbose: bool) -> std::result::Result<(), Box<dyn s
482
545
 
483
546
  // Compile HTML files
484
547
  if !html_files.is_empty() {
548
+ // Always clear component cache before recompiling so components
549
+ // are read fresh from disk (prevents stale cache from previous triggers)
550
+ watch_compiler.clear_component_cache();
551
+
485
552
  match watch_compiler.compile_specific_html_files(&html_files, &parser) {
486
553
  Ok(stats) => {
487
554
  total_stats.files_compiled = stats.files_compiled;
@@ -44,6 +44,10 @@ pub struct VibeCompilerConfig {
44
44
  pub runtime_as_is: bool,
45
45
  #[serde(default)]
46
46
  pub iterations_as_is: bool,
47
+ #[serde(default)]
48
+ pub no_clean: bool,
49
+ #[serde(default)]
50
+ pub fouc_as_is: bool,
47
51
  }
48
52
 
49
53
  fn default_source() -> String { "./".to_string() }
@@ -99,6 +103,8 @@ impl Default for VibeCompilerConfig {
99
103
  components_as_is: false,
100
104
  runtime_as_is: false,
101
105
  iterations_as_is: false,
106
+ no_clean: false,
107
+ fouc_as_is: false,
102
108
  }
103
109
  }
104
110
  }
@@ -122,6 +128,8 @@ pub struct Config {
122
128
  pub components_as_is: bool,
123
129
  pub runtime_as_is: bool,
124
130
  pub iterations_as_is: bool,
131
+ pub no_clean: bool,
132
+ pub fouc_as_is: bool,
125
133
  pub working_dir: PathBuf,
126
134
  }
127
135
 
@@ -168,6 +176,8 @@ impl Config {
168
176
  components_as_is: config.components_as_is,
169
177
  runtime_as_is: config.runtime_as_is,
170
178
  iterations_as_is: config.iterations_as_is,
179
+ no_clean: config.no_clean,
180
+ fouc_as_is: config.fouc_as_is,
171
181
  working_dir,
172
182
  }
173
183
  }