@ape-egg/vibe 1.6.1 → 1.7.1
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 +62 -2
- package/README.md +19 -10
- package/boot.js +11 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +67 -197
- package/compiler/src/Cargo.toml +2 -2
- package/compiler/src/compiler/compile.rs +136 -38
- package/compiler/src/compiler/component_tagger.rs +68 -40
- package/compiler/src/compiler/value_stamper.rs +75 -5
- package/compiler/src/compiler/watcher.rs +69 -2
- package/compiler/src/config.rs +10 -5
- package/compiler/src/main.rs +38 -25
- package/compiler/src/parser/html.rs +204 -87
- 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 +18 -20
- package/runtime/index.js +20 -2
- package/runtime/iterate.js +63 -15
- package/runtime/parse.js +3 -2
- package/runtime/pre-compiled-manifest.js +115 -58
|
@@ -71,6 +71,21 @@ impl HtmlParser {
|
|
|
71
71
|
Ok(())
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/// Reload a single element from disk (when the file changes in watch mode)
|
|
75
|
+
pub fn reload_element(&mut self, path: &Path) -> Result<(), ParseError> {
|
|
76
|
+
if path.extension().map_or(false, |ext| ext == "html") {
|
|
77
|
+
if let Some(tag_name) = path.file_stem().and_then(|s| s.to_str()) {
|
|
78
|
+
let content = fs::read_to_string(path).map_err(|e| ParseError::ReadError {
|
|
79
|
+
path: path.display().to_string(),
|
|
80
|
+
source: e,
|
|
81
|
+
})?;
|
|
82
|
+
let element = Element::new(tag_name.to_string(), path.to_path_buf(), content);
|
|
83
|
+
self.cache.insert(tag_name.to_string(), element);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
Ok(())
|
|
87
|
+
}
|
|
88
|
+
|
|
74
89
|
/// Get an element by tag name
|
|
75
90
|
pub fn _get_element(&self, tag_name: &str) -> Option<&Element> {
|
|
76
91
|
self.cache.get(tag_name)
|
|
@@ -115,8 +130,20 @@ impl HtmlParser {
|
|
|
115
130
|
|
|
116
131
|
// Step 2: Handle explicit <component src="..."> elements
|
|
117
132
|
// If components_as_is is false, recursively inline all <component> elements
|
|
133
|
+
// Keep running until no more components are found (handles nested components in slots)
|
|
118
134
|
if !components_as_is {
|
|
119
|
-
|
|
135
|
+
let mut iterations = 0;
|
|
136
|
+
let max_iterations = 50; // Prevent infinite loops
|
|
137
|
+
loop {
|
|
138
|
+
let before = result.clone();
|
|
139
|
+
result = self.inline_component_elements(&result, external_cache);
|
|
140
|
+
iterations += 1;
|
|
141
|
+
|
|
142
|
+
// Stop if no changes or max iterations reached
|
|
143
|
+
if result == before || iterations >= max_iterations {
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
120
147
|
|
|
121
148
|
// NOTE: Don't run inline_custom_elements again here - it causes infinite recursion
|
|
122
149
|
// Custom elements inside components are already processed when the component was cached
|
|
@@ -228,6 +255,65 @@ impl HtmlParser {
|
|
|
228
255
|
/// Recursively inline all <component> elements with their HTML content
|
|
229
256
|
/// Inline a single component (one pass, no looping)
|
|
230
257
|
/// Used during recursive component fetching to resolve nested components
|
|
258
|
+
/// Find the start position of the matching close tag, handling nesting of the same tag.
|
|
259
|
+
/// `after_open`: byte position immediately after the `>` of the opening tag.
|
|
260
|
+
fn find_matching_close(content: &str, after_open: usize, tag_name: &str) -> Option<usize> {
|
|
261
|
+
// \b ensures <component> matches but not <component-foo>
|
|
262
|
+
let open_re = regex::Regex::new(&format!(r"<{}\b", regex::escape(tag_name))).unwrap();
|
|
263
|
+
let close_re = regex::Regex::new(&format!(r"</{}>", regex::escape(tag_name))).unwrap();
|
|
264
|
+
|
|
265
|
+
let mut depth = 1i32;
|
|
266
|
+
let mut cursor = after_open;
|
|
267
|
+
|
|
268
|
+
while cursor < content.len() {
|
|
269
|
+
let slice = &content[cursor..];
|
|
270
|
+
let next_open = open_re.find(slice).map(|m| (m.start(), m.end()));
|
|
271
|
+
let next_close = close_re.find(slice).map(|m| (m.start(), m.end()));
|
|
272
|
+
|
|
273
|
+
match (next_open, next_close) {
|
|
274
|
+
(None, None) => return None,
|
|
275
|
+
(None, Some((cs, ce))) => {
|
|
276
|
+
depth -= 1;
|
|
277
|
+
if depth == 0 { return Some(cursor + cs); }
|
|
278
|
+
cursor += ce;
|
|
279
|
+
}
|
|
280
|
+
(Some((os, oe)), None) => {
|
|
281
|
+
// Check if self-closing by finding the end of this tag
|
|
282
|
+
if let Some(end) = slice[os..].find('>') {
|
|
283
|
+
if slice[os..os + end + 1].ends_with("/>") {
|
|
284
|
+
cursor += os + end + 1;
|
|
285
|
+
} else {
|
|
286
|
+
depth += 1;
|
|
287
|
+
cursor += oe;
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
return None;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
(Some((os, oe)), Some((cs, ce))) => {
|
|
294
|
+
if os < cs {
|
|
295
|
+
if let Some(end) = slice[os..].find('>') {
|
|
296
|
+
if slice[os..os + end + 1].ends_with("/>") {
|
|
297
|
+
cursor += os + end + 1;
|
|
298
|
+
} else {
|
|
299
|
+
depth += 1;
|
|
300
|
+
cursor += oe;
|
|
301
|
+
}
|
|
302
|
+
} else {
|
|
303
|
+
return None;
|
|
304
|
+
}
|
|
305
|
+
} else {
|
|
306
|
+
depth -= 1;
|
|
307
|
+
if depth == 0 { return Some(cursor + cs); }
|
|
308
|
+
cursor += ce;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
None
|
|
315
|
+
}
|
|
316
|
+
|
|
231
317
|
pub fn inline_single_component(&self, content: &str, component_src: &str, component_content: &str) -> String {
|
|
232
318
|
let mut result = content.to_string();
|
|
233
319
|
|
|
@@ -243,55 +329,53 @@ impl HtmlParser {
|
|
|
243
329
|
}
|
|
244
330
|
};
|
|
245
331
|
|
|
246
|
-
// Match
|
|
247
|
-
//
|
|
248
|
-
let pattern = format!(r#"
|
|
249
|
-
let
|
|
332
|
+
// Match only the opening tag — slot content is extracted via depth-counting close search
|
|
333
|
+
// to correctly handle slot content that contains </div> or nested <component> elements.
|
|
334
|
+
let pattern = format!(r#"<(component|div)\s+([^>]*)\bsrc="{}"\s*([^>]*)>"#, regex::escape(&normalized_src));
|
|
335
|
+
let open_re = regex::Regex::new(&pattern).unwrap();
|
|
250
336
|
|
|
251
|
-
let matches: Vec<_> =
|
|
252
|
-
let
|
|
337
|
+
let matches: Vec<_> = open_re.captures_iter(&result).filter_map(|cap| {
|
|
338
|
+
let open_tag = cap.get(0).unwrap();
|
|
253
339
|
let tag_name = cap.get(1).unwrap().as_str();
|
|
254
340
|
let attrs_before = cap.get(2).map(|m| m.as_str()).unwrap_or("");
|
|
255
341
|
let attrs_after = cap.get(3).map(|m| m.as_str()).unwrap_or("");
|
|
256
|
-
let slot_content = cap.get(4).map(|m| m.as_str().to_string());
|
|
257
342
|
|
|
258
343
|
// For <div>, verify it has class="component"
|
|
259
344
|
if tag_name == "div" {
|
|
260
345
|
let combined_attrs = format!("{} {}", attrs_before, attrs_after);
|
|
261
346
|
if !combined_attrs.contains("class=") || !combined_attrs.contains("component") {
|
|
262
|
-
return None;
|
|
347
|
+
return None;
|
|
263
348
|
}
|
|
264
349
|
}
|
|
265
350
|
|
|
266
|
-
|
|
351
|
+
let open_end = open_tag.end();
|
|
352
|
+
let close_start = Self::find_matching_close(&result, open_end, tag_name)?;
|
|
353
|
+
let close_end = close_start + format!("</{}>", tag_name).len();
|
|
354
|
+
let slot_content = result[open_end..close_start].to_string();
|
|
355
|
+
|
|
267
356
|
let attrs_str = format!("{} {}", attrs_before, attrs_after);
|
|
268
357
|
let props = Self::parse_props(&attrs_str);
|
|
269
358
|
|
|
270
|
-
Some((
|
|
271
|
-
}).
|
|
359
|
+
Some((open_tag.start(), close_end, props, slot_content))
|
|
360
|
+
}).collect();
|
|
361
|
+
|
|
362
|
+
// Replace from end to start (sorted descending by start position)
|
|
363
|
+
let mut sorted = matches;
|
|
364
|
+
sorted.sort_by(|a, b| b.0.cmp(&a.0));
|
|
272
365
|
|
|
273
|
-
|
|
274
|
-
for (start, end, props, slot_content) in matches.iter().rev() {
|
|
366
|
+
for (start, end, props, slot_content) in &sorted {
|
|
275
367
|
let mut replacement = component_content.to_string();
|
|
276
368
|
|
|
277
|
-
// Replace props
|
|
278
369
|
for (prop_name, prop_value) in props {
|
|
279
370
|
let prop_binding = format!("@[{}]", prop_name);
|
|
280
371
|
replacement = replacement.replace(&prop_binding, prop_value);
|
|
281
372
|
}
|
|
282
373
|
|
|
283
|
-
|
|
284
|
-
let slot_replacement = slot_content
|
|
285
|
-
.as_ref()
|
|
286
|
-
.filter(|s| !s.trim().is_empty())
|
|
287
|
-
.map(|s| s.as_str())
|
|
288
|
-
.unwrap_or("");
|
|
289
|
-
|
|
374
|
+
let slot_replacement = if slot_content.trim().is_empty() { "" } else { slot_content.as_str() };
|
|
290
375
|
replacement = replacement.replace("<slot></slot>", slot_replacement);
|
|
291
376
|
replacement = replacement.replace("<slot/>", slot_replacement);
|
|
292
377
|
replacement = replacement.replace("<slot />", slot_replacement);
|
|
293
378
|
|
|
294
|
-
// Keep the <component> wrapper
|
|
295
379
|
let wrapper = format!("<component>{}</component>", replacement);
|
|
296
380
|
result.replace_range(*start..*end, &wrapper);
|
|
297
381
|
}
|
|
@@ -302,73 +386,81 @@ impl HtmlParser {
|
|
|
302
386
|
fn inline_component_elements(&self, content: &str, external_cache: &HashMap<String, String>) -> String {
|
|
303
387
|
let mut result = content.to_string();
|
|
304
388
|
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
let component_re = regex::Regex::new(
|
|
310
|
-
r#"(?s)<(component|div)\s+([^>]*)\bsrc="([^"]+)"([^>]*)>(.*?)</(component|div)>"#
|
|
389
|
+
// Match only the opening component tag — slot content is extracted via depth-counting
|
|
390
|
+
// to correctly handle slot content containing </div> or nested <component> elements.
|
|
391
|
+
let open_re = regex::Regex::new(
|
|
392
|
+
r#"<(component|div)\s+([^>]*)\bsrc="([^"]+)"([^>]*)>"#
|
|
311
393
|
).unwrap();
|
|
312
394
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
let
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
let
|
|
324
|
-
|
|
325
|
-
|
|
395
|
+
// Process one match at a time with re-scanning after each replacement.
|
|
396
|
+
// This is required for nested components (e.g. Layout.html wrapping inner components):
|
|
397
|
+
// collecting all byte positions at once and applying them in reverse fails because
|
|
398
|
+
// replacing an inner component changes the string length, invalidating the outer
|
|
399
|
+
// component's end byte position and causing a char boundary panic.
|
|
400
|
+
loop {
|
|
401
|
+
let matches: Vec<_> = open_re.captures_iter(&result).filter_map(|cap| {
|
|
402
|
+
let open_tag = cap.get(0).unwrap();
|
|
403
|
+
let tag_name = cap.get(1).unwrap().as_str();
|
|
404
|
+
let attrs_before = cap.get(2).map(|m| m.as_str()).unwrap_or("");
|
|
405
|
+
let src = cap.get(3).unwrap().as_str();
|
|
406
|
+
let attrs_after = cap.get(4).map(|m| m.as_str()).unwrap_or("");
|
|
407
|
+
|
|
408
|
+
// For <div>, verify it has class="component"
|
|
409
|
+
if tag_name == "div" {
|
|
410
|
+
let combined_attrs = format!("{} {}", attrs_before, attrs_after);
|
|
411
|
+
if !combined_attrs.contains("class=") || !combined_attrs.contains("component") {
|
|
412
|
+
return None;
|
|
413
|
+
}
|
|
326
414
|
}
|
|
327
|
-
}
|
|
328
415
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
416
|
+
let open_end = open_tag.end();
|
|
417
|
+
let close_start = Self::find_matching_close(&result, open_end, tag_name)?;
|
|
418
|
+
let close_end = close_start + format!("</{}>", tag_name).len();
|
|
419
|
+
let slot_content = result[open_end..close_start].to_string();
|
|
332
420
|
|
|
333
|
-
|
|
334
|
-
|
|
421
|
+
let attrs_str = format!("{} {}", attrs_before, attrs_after);
|
|
422
|
+
let props = Self::parse_props(&attrs_str);
|
|
335
423
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
424
|
+
Some((open_tag.start(), close_end, src.to_string(), props, slot_content))
|
|
425
|
+
}).collect();
|
|
426
|
+
|
|
427
|
+
// Pick the rightmost match that has a cached component and apply it, then re-scan.
|
|
428
|
+
let mut sorted = matches;
|
|
429
|
+
sorted.sort_by(|a, b| b.0.cmp(&a.0));
|
|
430
|
+
|
|
431
|
+
let mut applied = false;
|
|
432
|
+
for (start, end, src, props, slot_content) in sorted {
|
|
433
|
+
let normalized_src = if src.starts_with("http://") || src.starts_with("https://") {
|
|
434
|
+
src.to_string()
|
|
345
435
|
} else {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
for (prop_name, prop_value) in props {
|
|
354
|
-
let prop_binding = format!("@[{}]", prop_name);
|
|
355
|
-
replacement = replacement.replace(&prop_binding, prop_value);
|
|
356
|
-
}
|
|
436
|
+
let without_prefix = src.trim_start_matches("./");
|
|
437
|
+
if without_prefix.starts_with('/') {
|
|
438
|
+
without_prefix.to_string()
|
|
439
|
+
} else {
|
|
440
|
+
format!("/{}", without_prefix)
|
|
441
|
+
}
|
|
442
|
+
};
|
|
357
443
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
.unwrap_or("");
|
|
444
|
+
if let Some(mut replacement) = external_cache.get(&normalized_src).cloned() {
|
|
445
|
+
for (prop_name, prop_value) in &props {
|
|
446
|
+
let prop_binding = format!("@[{}]", prop_name);
|
|
447
|
+
replacement = replacement.replace(&prop_binding, prop_value);
|
|
448
|
+
}
|
|
364
449
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
450
|
+
let slot_replacement = if slot_content.trim().is_empty() { "" } else { slot_content.as_str() };
|
|
451
|
+
replacement = replacement.replace("<slot></slot>", slot_replacement);
|
|
452
|
+
replacement = replacement.replace("<slot/>", slot_replacement);
|
|
453
|
+
replacement = replacement.replace("<slot />", slot_replacement);
|
|
454
|
+
|
|
455
|
+
let wrapper = format!("<component>{}</component>", replacement);
|
|
456
|
+
result.replace_range(start..end, &wrapper);
|
|
457
|
+
applied = true;
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
368
461
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
result.replace_range(*start..*end, &wrapper);
|
|
462
|
+
if !applied {
|
|
463
|
+
break;
|
|
372
464
|
}
|
|
373
465
|
}
|
|
374
466
|
|
|
@@ -440,16 +532,41 @@ fn transform_custom_tags_to_divs(content: &str, reserved_elements: &[String]) ->
|
|
|
440
532
|
}
|
|
441
533
|
}
|
|
442
534
|
|
|
535
|
+
// Sort by descending length so more specific tags (e.g. "accordion-content")
|
|
536
|
+
// are processed before shorter prefixes (e.g. "accordion"), preventing
|
|
537
|
+
// partial tag-name matches like <accordion([^>]*)> matching <accordion-content>
|
|
538
|
+
custom_tags.sort_by(|a, b| b.len().cmp(&a.len()));
|
|
539
|
+
|
|
540
|
+
// Pre-compile class attribute regex for merging existing class values
|
|
541
|
+
let class_attr_re = regex::Regex::new(r#"\bclass="([^"]*)""#).unwrap();
|
|
542
|
+
|
|
443
543
|
// Transform each custom tag
|
|
444
|
-
for tag in custom_tags {
|
|
445
|
-
// Opening tag:
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
544
|
+
for tag in &custom_tags {
|
|
545
|
+
// Opening tag: require whitespace or end-of-tag after the tag name so that
|
|
546
|
+
// <accordion> does not accidentally match <accordion-content>
|
|
547
|
+
let open_re = regex::Regex::new(
|
|
548
|
+
&format!(r"<{}([\s][^>]*|)>", regex::escape(tag))
|
|
549
|
+
).unwrap();
|
|
550
|
+
|
|
551
|
+
result = open_re.replace_all(&result, |caps: ®ex::Captures| -> String {
|
|
552
|
+
let attrs = caps.get(1).map(|m| m.as_str()).unwrap_or("");
|
|
553
|
+
|
|
554
|
+
// If the element already has class="...", merge tag name with existing value
|
|
555
|
+
if let Some(class_cap) = class_attr_re.captures(attrs) {
|
|
556
|
+
let existing = class_cap.get(1).unwrap().as_str();
|
|
557
|
+
let merged = format!("{} {}", tag, existing);
|
|
558
|
+
let new_attrs = class_attr_re.replace(
|
|
559
|
+
attrs,
|
|
560
|
+
format!(r#"class="{}""#, merged.trim()).as_str(),
|
|
561
|
+
);
|
|
562
|
+
format!("<div{}>", new_attrs)
|
|
563
|
+
} else {
|
|
564
|
+
format!("<div class=\"{}\"{}>", tag, attrs)
|
|
565
|
+
}
|
|
566
|
+
}).to_string();
|
|
450
567
|
|
|
451
568
|
// Closing tag: </custom-tag> -> </div>
|
|
452
|
-
let close_re = regex::Regex::new(&format!(r"</{}>", regex::escape(
|
|
569
|
+
let close_re = regex::Regex::new(&format!(r"</{}>", regex::escape(tag))).unwrap();
|
|
453
570
|
result = close_re.replace_all(&result, "</div>").to_string();
|
|
454
571
|
}
|
|
455
572
|
|
package/index.js
CHANGED
|
@@ -1,15 +1,41 @@
|
|
|
1
1
|
// Universal entry point for Vibe
|
|
2
2
|
// Usage: import vibe from 'vibe/index.js'; vibe({ initialState }, { debug: true }, 'body');
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { boot, isBooted, ensureBoot } from './boot.js';
|
|
5
|
+
|
|
6
|
+
// Shared instance for queueing listeners before boot
|
|
7
|
+
let vibeInstance = null;
|
|
8
|
+
|
|
9
|
+
const createVibeInstance = () => ({
|
|
10
|
+
_pendingListeners: { afterUpdate: [], afterDomMutation: [], ready: [] },
|
|
11
|
+
on(event, callback) {
|
|
12
|
+
// If booted, delegate to window.$
|
|
13
|
+
if (isBooted() && window.$) {
|
|
14
|
+
return window.$.on(event, callback);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Otherwise queue for later
|
|
18
|
+
if (this._pendingListeners[event]) {
|
|
19
|
+
this._pendingListeners[event].push(callback);
|
|
20
|
+
}
|
|
21
|
+
return () => {
|
|
22
|
+
this._pendingListeners[event] = this._pendingListeners[event].filter(cb => cb !== callback);
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
});
|
|
5
26
|
|
|
6
27
|
const vibe = (state = {}, config, targetSelector) => {
|
|
7
28
|
if (isBooted()) {
|
|
8
|
-
// Already booted -
|
|
29
|
+
// Already booted - merge state into live proxy
|
|
9
30
|
Object.assign(window.$, state);
|
|
10
31
|
return window.$;
|
|
11
32
|
}
|
|
12
33
|
|
|
34
|
+
// Create shared instance on first call
|
|
35
|
+
if (!vibeInstance) {
|
|
36
|
+
vibeInstance = createVibeInstance();
|
|
37
|
+
}
|
|
38
|
+
|
|
13
39
|
// Not booted yet - accumulate in global state registry
|
|
14
40
|
if (!window.__vibeGlobalState) {
|
|
15
41
|
window.__vibeGlobalState = {};
|
|
@@ -26,15 +52,18 @@ const vibe = (state = {}, config, targetSelector) => {
|
|
|
26
52
|
window.__vibeTargetSelector = targetSelector;
|
|
27
53
|
}
|
|
28
54
|
|
|
29
|
-
// Explicit boot call (no state passed means "boot now")
|
|
55
|
+
// Explicit boot call (no state passed means "boot now with accumulated state")
|
|
30
56
|
if (Object.keys(state).length === 0 && Object.keys(window.__vibeGlobalState).length > 0) {
|
|
31
57
|
return boot();
|
|
32
58
|
}
|
|
33
59
|
|
|
34
|
-
//
|
|
60
|
+
// Queue boot in microtask to allow all component scripts to register
|
|
35
61
|
ensureBoot();
|
|
36
62
|
|
|
37
|
-
return
|
|
63
|
+
return vibeInstance;
|
|
38
64
|
};
|
|
39
65
|
|
|
66
|
+
// Export function to get pending listeners (used by boot.js)
|
|
67
|
+
export const getPendingListeners = () => vibeInstance?._pendingListeners || null;
|
|
68
|
+
|
|
40
69
|
export default vibe;
|
package/llms.txt
CHANGED
|
@@ -129,7 +129,7 @@ Conditionals can be nested inside iterations and vice versa.
|
|
|
129
129
|
Skip reactive processing for an element and its children:
|
|
130
130
|
|
|
131
131
|
```html
|
|
132
|
-
<code dehydrate>@[this] displays literally, not parsed</code>
|
|
132
|
+
<code vibe-dehydrate>@[this] displays literally, not parsed</code>
|
|
133
133
|
```
|
|
134
134
|
|
|
135
135
|
Use cases:
|
package/package.json
CHANGED
package/runtime/cleanup.js
CHANGED
|
@@ -68,13 +68,4 @@ export const cleanup = (rootElement, debug = false) => {
|
|
|
68
68
|
debug,
|
|
69
69
|
);
|
|
70
70
|
}
|
|
71
|
-
|
|
72
|
-
// Dispatch ready event to signal that Vibe has completed all initial processing
|
|
73
|
-
if (typeof document !== 'undefined') {
|
|
74
|
-
document.dispatchEvent(
|
|
75
|
-
new CustomEvent('vibe:ready', {
|
|
76
|
-
detail: { rootElement, cleanName, isClass },
|
|
77
|
-
}),
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
71
|
};
|
package/runtime/component.js
CHANGED
|
@@ -36,7 +36,9 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
36
36
|
const componentElements = rootElement.querySelectorAll('component[src], div.component[src]');
|
|
37
37
|
|
|
38
38
|
if (componentElements.length === 0) {
|
|
39
|
-
|
|
39
|
+
// Defer onComplete to give user code a chance to register listeners
|
|
40
|
+
// This is important when all components are pre-compiled (no src attributes)
|
|
41
|
+
if (onComplete) queueMicrotask(() => onComplete());
|
|
40
42
|
return;
|
|
41
43
|
}
|
|
42
44
|
|
|
@@ -50,15 +52,18 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
50
52
|
// Process next component
|
|
51
53
|
processComponent(rootElement, onComplete, config);
|
|
52
54
|
} else {
|
|
53
|
-
|
|
55
|
+
// Defer onComplete to give user code a chance to register listeners
|
|
56
|
+
if (onComplete) queueMicrotask(() => onComplete());
|
|
54
57
|
}
|
|
55
58
|
return;
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
const src = el.getAttribute('src');
|
|
59
62
|
|
|
60
|
-
//
|
|
61
|
-
|
|
63
|
+
// Use pre-hydration slot content if available (saved by index.js before hydration ran),
|
|
64
|
+
// otherwise fall back to current innerHTML (e.g. runtime-only usage without boot).
|
|
65
|
+
const children = (el._vibeSlotContent !== undefined ? el._vibeSlotContent : el.innerHTML).trim();
|
|
66
|
+
delete el._vibeSlotContent;
|
|
62
67
|
const props = {};
|
|
63
68
|
Array.from(el.attributes).forEach((attr) => {
|
|
64
69
|
if (attr.name !== 'src') {
|
|
@@ -131,21 +136,21 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
131
136
|
const thisRegex = /@\[this\.(\w+)\]/g;
|
|
132
137
|
|
|
133
138
|
// Rewrite in text nodes
|
|
134
|
-
Array.from(element.childNodes).forEach(node => {
|
|
139
|
+
Array.from(element.childNodes).forEach((node) => {
|
|
135
140
|
if (node.nodeType === Node.TEXT_NODE && node.textContent.includes('@[this.')) {
|
|
136
141
|
node.textContent = node.textContent.replace(thisRegex, `@[${componentId}.$1]`);
|
|
137
142
|
}
|
|
138
143
|
});
|
|
139
144
|
|
|
140
145
|
// Rewrite in attributes
|
|
141
|
-
Array.from(element.attributes || []).forEach(attr => {
|
|
146
|
+
Array.from(element.attributes || []).forEach((attr) => {
|
|
142
147
|
if (attr.value.includes('@[this.')) {
|
|
143
148
|
attr.value = attr.value.replace(thisRegex, `@[${componentId}.$1]`);
|
|
144
149
|
}
|
|
145
150
|
});
|
|
146
151
|
|
|
147
152
|
// Recurse into children
|
|
148
|
-
Array.from(element.children).forEach(child => {
|
|
153
|
+
Array.from(element.children).forEach((child) => {
|
|
149
154
|
rewriteThisBindings(child);
|
|
150
155
|
});
|
|
151
156
|
};
|
|
@@ -195,9 +200,10 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
195
200
|
// Check if element still has a parent (might have been removed during fetch)
|
|
196
201
|
if (el.parentNode) {
|
|
197
202
|
// Create clean wrapper element (preserve tag type: component or div.component)
|
|
198
|
-
const newWrapper =
|
|
199
|
-
|
|
200
|
-
|
|
203
|
+
const newWrapper =
|
|
204
|
+
el.tagName === 'DIV'
|
|
205
|
+
? document.createElement('div')
|
|
206
|
+
: document.createElement('component');
|
|
201
207
|
|
|
202
208
|
if (el.tagName === 'DIV') {
|
|
203
209
|
newWrapper.className = 'component';
|
|
@@ -207,16 +213,8 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
207
213
|
el.replaceWith(newWrapper);
|
|
208
214
|
debugLog(PHASE_FETCH, src, debug);
|
|
209
215
|
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
if (config._forceSync && config._processMutations && config._observer) {
|
|
213
|
-
Promise.resolve().then(() => {
|
|
214
|
-
const pending = config._observer.takeRecords();
|
|
215
|
-
if (pending.length > 0) {
|
|
216
|
-
config._processMutations(pending);
|
|
217
|
-
}
|
|
218
|
-
});
|
|
219
|
-
}
|
|
216
|
+
// Let MutationObserver handle the mutation naturally
|
|
217
|
+
// It will call processMutations, which will call processComponent for the next component
|
|
220
218
|
}
|
|
221
219
|
})
|
|
222
220
|
.catch((error) => {
|
package/runtime/index.js
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
PHASE_UPDATE,
|
|
19
19
|
PHASE_MUTATE,
|
|
20
20
|
PHASE_HYPERSPEED,
|
|
21
|
+
PHASE_READY,
|
|
21
22
|
DEHYDRATE_CLASS_OR_ATTR,
|
|
22
23
|
} from './constants.js';
|
|
23
24
|
import { processComponent, abortComponentFetch } from './component.js';
|
|
@@ -70,12 +71,12 @@ const shouldProcessNode = (node) => {
|
|
|
70
71
|
// Navigate tree using dot notation (handles .children at each level)
|
|
71
72
|
const navigateTree = (tree, path) => {
|
|
72
73
|
if (!path) return tree;
|
|
73
|
-
return path.split('.').reduce((node, key) => node?.children?.[key], tree);
|
|
74
|
+
return path.split('.').filter(k => k).reduce((node, key) => node?.children?.[key], tree);
|
|
74
75
|
};
|
|
75
76
|
|
|
76
77
|
// Get or create a node in the tree at the given path
|
|
77
78
|
const ensureNode = (tree, path) => {
|
|
78
|
-
const keys = path.split('.');
|
|
79
|
+
const keys = path.split('.').filter(k => k);
|
|
79
80
|
return keys.reduce((node, key) => {
|
|
80
81
|
if (!node.children[key]) {
|
|
81
82
|
node.children[key] = { children: {} };
|
|
@@ -453,6 +454,13 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
453
454
|
restoreMarkersFromManifest(rootElement, cloneForRestoration, hyperspeedTree);
|
|
454
455
|
}
|
|
455
456
|
|
|
457
|
+
// Save raw slot content of fetched components before hydration replaces @[...] markers.
|
|
458
|
+
// component.js captures el.innerHTML when resolving — if hydration already ran, the
|
|
459
|
+
// binding syntax is gone and the resolved component won't be reactive.
|
|
460
|
+
rootElement.querySelectorAll('component[src], div.component[src]').forEach(el => {
|
|
461
|
+
el._vibeSlotContent = el.innerHTML;
|
|
462
|
+
});
|
|
463
|
+
|
|
456
464
|
// Runtime parses DOM (which now has restored markers if hyperspeed was used)
|
|
457
465
|
let parsedTree = parse(rootElement);
|
|
458
466
|
|
|
@@ -497,6 +505,7 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
497
505
|
const hooks = {
|
|
498
506
|
afterUpdate: [],
|
|
499
507
|
afterDomMutation: [],
|
|
508
|
+
ready: [],
|
|
500
509
|
};
|
|
501
510
|
|
|
502
511
|
// Extract plain values from proxy (removes proxy wrappers)
|
|
@@ -950,6 +959,15 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
950
959
|
if (shouldCleanup(rootElement)) {
|
|
951
960
|
cleanup(rootElement, debug);
|
|
952
961
|
cleanupExecuted = true;
|
|
962
|
+
|
|
963
|
+
// Fire ready hook after cleanup completes
|
|
964
|
+
hooks.ready.forEach((callback) => {
|
|
965
|
+
try {
|
|
966
|
+
callback();
|
|
967
|
+
} catch (error) {
|
|
968
|
+
console.error('[vibe] Error in ready hook:', error);
|
|
969
|
+
}
|
|
970
|
+
});
|
|
953
971
|
}
|
|
954
972
|
};
|
|
955
973
|
|