@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 +44 -1
- package/README.md +19 -8
- package/boot.js +11 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/src/Cargo.lock +1 -1
- package/compiler/src/Cargo.toml +1 -1
- package/compiler/src/compiler/compile.rs +38 -12
- package/compiler/src/compiler/component_tagger.rs +22 -3
- package/compiler/src/compiler/value_stamper.rs +75 -5
- package/compiler/src/compiler/watcher.rs +69 -2
- package/compiler/src/config.rs +10 -0
- package/compiler/src/main.rs +39 -15
- package/compiler/src/parser/html.rs +61 -9
- package/index.js +34 -5
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/runtime/cleanup.js +0 -9
- package/runtime/component.js +14 -18
- package/runtime/index.js +13 -2
- package/runtime/iterate.js +63 -15
- package/runtime/parse.js +3 -2
- package/runtime/pre-compiled-manifest.js +111 -58
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
|
|
27
|
-
|
|
28
|
-
|
|
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>
|
|
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>
|
|
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++;
|
|
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.
|
|
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
|
|
|
Binary file
|
package/compiler/src/Cargo.lock
CHANGED
package/compiler/src/Cargo.toml
CHANGED
|
@@ -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
|
|
825
|
+
let pre_rendered = stamper.stamp_html(html.to_string())?;
|
|
804
826
|
|
|
805
|
-
//
|
|
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
|
-
|
|
1216
|
-
&
|
|
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
|
-
|
|
1253
|
-
&
|
|
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
|
|
1467
|
-
if
|
|
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
|
-
//
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
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;
|
package/compiler/src/config.rs
CHANGED
|
@@ -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
|
}
|