@ape-egg/vibe 2.0.0 → 2.0.5
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 +78 -0
- package/README.md +1 -1
- package/ROADMAP.md +45 -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 +100 -13
- package/compiler/src/compiler/iteration_optimizer.rs +188 -112
- package/compiler/src/compiler/manifest_builder.rs +5 -9
- package/compiler/src/parser/html.rs +194 -31
- package/package.json +1 -1
- package/runtime/cleanup.js +12 -3
- package/runtime/component.js +130 -43
- package/runtime/conditionals.js +16 -9
- package/runtime/hydrate.js +12 -3
- package/runtime/index.js +23 -2
- package/runtime/iterate.js +103 -22
- package/runtime/loop-scope.js +49 -3
- package/runtime/parse.js +8 -0
- package/runtime/pre-compiled-manifest.js +56 -155
- package/runtime/utils.js +12 -2
|
@@ -395,19 +395,15 @@ impl ManifestBuilder {
|
|
|
395
395
|
}
|
|
396
396
|
}
|
|
397
397
|
|
|
398
|
-
// Extract template HTML between start and end
|
|
398
|
+
// Extract template HTML between start and end — VERBATIM, including
|
|
399
|
+
// whitespace-only text nodes. The manifest's child keys are childNodes
|
|
400
|
+
// indices computed on the pre-stamp DOM; restoration re-inserts this
|
|
401
|
+
// template, and only an exact node-count round-trip keeps those
|
|
402
|
+
// indices valid for every sibling that follows the restored region.
|
|
399
403
|
if let Some(end) = end_idx {
|
|
400
404
|
let template_nodes: Vec<_> = siblings.iter()
|
|
401
405
|
.skip(start_idx + 1)
|
|
402
406
|
.take(end - start_idx - 1)
|
|
403
|
-
.filter(|node| {
|
|
404
|
-
// Skip whitespace-only text nodes
|
|
405
|
-
if let NodeData::Text { contents } = &node.data {
|
|
406
|
-
!contents.borrow().trim().is_empty()
|
|
407
|
-
} else {
|
|
408
|
-
true // Keep all non-text nodes
|
|
409
|
-
}
|
|
410
|
-
})
|
|
411
407
|
.collect();
|
|
412
408
|
|
|
413
409
|
return self.serialize_nodes(&template_nodes);
|
|
@@ -195,7 +195,7 @@ impl HtmlParser {
|
|
|
195
195
|
let closing_pattern = format!(r"</{}>", regex::escape(tag_name));
|
|
196
196
|
|
|
197
197
|
// Find all occurrences
|
|
198
|
-
let mut matches: Vec<(usize, usize, String,
|
|
198
|
+
let mut matches: Vec<(usize, usize, String, Vec<(String, String)>)> = Vec::new();
|
|
199
199
|
|
|
200
200
|
// Find opening tags
|
|
201
201
|
for cap in tag_re.find_iter(&result) {
|
|
@@ -228,14 +228,9 @@ impl HtmlParser {
|
|
|
228
228
|
// Replace from end to start to maintain indices
|
|
229
229
|
matches.reverse();
|
|
230
230
|
for (start, end, slot_content, props) in matches {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
// replace @[headline] in content with @[pageTitle]
|
|
235
|
-
for (prop_name, prop_value) in props {
|
|
236
|
-
let prop_binding = format!("@[{}]", prop_name);
|
|
237
|
-
replacement = replacement.replace(&prop_binding, &prop_value);
|
|
238
|
-
}
|
|
231
|
+
// Substitute props with full runtime parity (bindings,
|
|
232
|
+
// expressions, directive comments, event handlers)
|
|
233
|
+
let mut replacement = substitute_props(&element.content, &props);
|
|
239
234
|
|
|
240
235
|
// Replace <slot> tags — wrap children in <slot> boundary, or remove if empty
|
|
241
236
|
let slot_wrapped = if slot_content.trim().is_empty() {
|
|
@@ -360,7 +355,10 @@ impl HtmlParser {
|
|
|
360
355
|
let slot_content = result[open_end..close_start].to_string();
|
|
361
356
|
|
|
362
357
|
let attrs_str = format!("{} {}", attrs_before, attrs_after);
|
|
363
|
-
let props = Self::parse_props(&attrs_str)
|
|
358
|
+
let props: Vec<(String, String)> = Self::parse_props(&attrs_str)
|
|
359
|
+
.into_iter()
|
|
360
|
+
.filter(|(n, _)| n != "src" && n != "class")
|
|
361
|
+
.collect();
|
|
364
362
|
|
|
365
363
|
Some((open_tag.start(), close_end, props, slot_content))
|
|
366
364
|
}).collect();
|
|
@@ -370,12 +368,8 @@ impl HtmlParser {
|
|
|
370
368
|
sorted.sort_by(|a, b| b.0.cmp(&a.0));
|
|
371
369
|
|
|
372
370
|
for (start, end, props, slot_content) in &sorted {
|
|
373
|
-
let mut replacement = component_content
|
|
374
|
-
|
|
375
|
-
for (prop_name, prop_value) in props {
|
|
376
|
-
let prop_binding = format!("@[{}]", prop_name);
|
|
377
|
-
replacement = replacement.replace(&prop_binding, prop_value);
|
|
378
|
-
}
|
|
371
|
+
let mut replacement = substitute_props(component_content, props);
|
|
372
|
+
replacement = neuter_component_scripts(&replacement);
|
|
379
373
|
|
|
380
374
|
let slot_wrapped = if slot_content.trim().is_empty() { String::new() } else { format!("<slot>{}</slot>", slot_content) };
|
|
381
375
|
replacement = replacement.replace("<slot></slot>", &slot_wrapped);
|
|
@@ -425,7 +419,10 @@ impl HtmlParser {
|
|
|
425
419
|
let slot_content = result[open_end..close_start].to_string();
|
|
426
420
|
|
|
427
421
|
let attrs_str = format!("{} {}", attrs_before, attrs_after);
|
|
428
|
-
let props = Self::parse_props(&attrs_str)
|
|
422
|
+
let props: Vec<(String, String)> = Self::parse_props(&attrs_str)
|
|
423
|
+
.into_iter()
|
|
424
|
+
.filter(|(n, _)| n != "src" && n != "class")
|
|
425
|
+
.collect();
|
|
429
426
|
|
|
430
427
|
Some((open_tag.start(), close_end, src.to_string(), props, slot_content))
|
|
431
428
|
}).collect();
|
|
@@ -447,11 +444,9 @@ impl HtmlParser {
|
|
|
447
444
|
}
|
|
448
445
|
};
|
|
449
446
|
|
|
450
|
-
if let Some(
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
replacement = replacement.replace(&prop_binding, prop_value);
|
|
454
|
-
}
|
|
447
|
+
if let Some(template) = external_cache.get(&normalized_src) {
|
|
448
|
+
let mut replacement = substitute_props(template, &props);
|
|
449
|
+
replacement = neuter_component_scripts(&replacement);
|
|
455
450
|
|
|
456
451
|
let slot_wrapped = if slot_content.trim().is_empty() { String::new() } else { format!("<slot>{}</slot>", slot_content) };
|
|
457
452
|
replacement = replacement.replace("<slot></slot>", &slot_wrapped);
|
|
@@ -473,24 +468,192 @@ impl HtmlParser {
|
|
|
473
468
|
result
|
|
474
469
|
}
|
|
475
470
|
|
|
476
|
-
/// Parse component props from attributes string
|
|
477
|
-
///
|
|
478
|
-
|
|
479
|
-
|
|
471
|
+
/// Parse component props from attributes string, in source order.
|
|
472
|
+
/// Supports valued (`headline="@[pageTitle]"`) and bare boolean
|
|
473
|
+
/// (`dndDisabled`) attributes — the runtime receives bare attributes from
|
|
474
|
+
/// the DOM with an empty-string value, so they're captured the same here.
|
|
475
|
+
fn parse_props(attrs_str: &str) -> Vec<(String, String)> {
|
|
476
|
+
let mut props = Vec::new();
|
|
480
477
|
|
|
481
|
-
|
|
482
|
-
let attr_re = regex::Regex::new(r#"(\w+)="([^"]*)""#).unwrap();
|
|
478
|
+
let attr_re = regex::Regex::new(r#"([\w-]+)(?:="([^"]*)")?"#).unwrap();
|
|
483
479
|
|
|
484
480
|
for cap in attr_re.captures_iter(attrs_str) {
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
481
|
+
let name = cap.get(1).unwrap().as_str().to_string();
|
|
482
|
+
let value = cap.get(2).map(|m| m.as_str().to_string()).unwrap_or_default();
|
|
483
|
+
props.push((name, value));
|
|
488
484
|
}
|
|
489
485
|
|
|
490
486
|
props
|
|
491
487
|
}
|
|
492
488
|
}
|
|
493
489
|
|
|
490
|
+
/// Neuter component scripts inlined from `<component src>` templates:
|
|
491
|
+
/// `<script type="module">` becomes `<script type="vibe-module">` so the
|
|
492
|
+
/// browser does NOT execute it as a native page module (wrong timing —
|
|
493
|
+
/// pre-boot, placeholder `$`, no live state merge). The runtime executes
|
|
494
|
+
/// vibe-module scripts through the same injected-component() path it uses
|
|
495
|
+
/// for fetched component scripts, giving compiled and runtime pages one
|
|
496
|
+
/// script-execution pipeline with identical semantics. Runs before slot
|
|
497
|
+
/// inlining so caller-authored slot scripts stay native.
|
|
498
|
+
fn neuter_component_scripts(html: &str) -> String {
|
|
499
|
+
html.replace("<script type=\"module\"", "<script type=\"vibe-module\"")
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
fn is_ident_byte(b: u8) -> bool {
|
|
503
|
+
b.is_ascii_alphanumeric() || b == b'_' || b == b'$'
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/// Case-insensitive free-identifier substitution, mirroring the runtime's
|
|
507
|
+
/// renderPropsAndSlot idRegex: the name must not be preceded by an identifier
|
|
508
|
+
/// character or `.` (property access) and not followed by an identifier
|
|
509
|
+
/// character. Case-insensitive because HTML lowercases attribute names while
|
|
510
|
+
/// component templates reference the author's camelCase identifiers.
|
|
511
|
+
fn substitute_identifier(expr: &str, name: &str, replacement: &str) -> String {
|
|
512
|
+
let bytes = expr.as_bytes();
|
|
513
|
+
let nb = name.as_bytes();
|
|
514
|
+
let nlen = nb.len();
|
|
515
|
+
if nlen == 0 {
|
|
516
|
+
return expr.to_string();
|
|
517
|
+
}
|
|
518
|
+
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
|
|
519
|
+
let mut i = 0;
|
|
520
|
+
while i < bytes.len() {
|
|
521
|
+
if i + nlen <= bytes.len()
|
|
522
|
+
&& bytes[i..i + nlen].eq_ignore_ascii_case(nb)
|
|
523
|
+
&& (i == 0 || (!is_ident_byte(bytes[i - 1]) && bytes[i - 1] != b'.'))
|
|
524
|
+
&& (i + nlen == bytes.len() || !is_ident_byte(bytes[i + nlen]))
|
|
525
|
+
{
|
|
526
|
+
out.extend_from_slice(replacement.as_bytes());
|
|
527
|
+
i += nlen;
|
|
528
|
+
} else {
|
|
529
|
+
out.push(bytes[i]);
|
|
530
|
+
i += 1;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
String::from_utf8(out).unwrap()
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/// `$.propName` rewrites inside event-handler bodies (the runtime's stateRegex
|
|
537
|
+
/// pass): for a binding prop `value="@[email]"`, `$.value = this.value`
|
|
538
|
+
/// becomes `$.email = this.value` — DOM property reads (`this.value`) stay.
|
|
539
|
+
fn substitute_state_ref(body: &str, name: &str, replacement: &str) -> String {
|
|
540
|
+
let bytes = body.as_bytes();
|
|
541
|
+
let nb = name.as_bytes();
|
|
542
|
+
let nlen = nb.len();
|
|
543
|
+
if nlen == 0 {
|
|
544
|
+
return body.to_string();
|
|
545
|
+
}
|
|
546
|
+
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
|
|
547
|
+
let mut i = 0;
|
|
548
|
+
while i < bytes.len() {
|
|
549
|
+
if bytes[i] == b'$'
|
|
550
|
+
&& i + 2 + nlen <= bytes.len()
|
|
551
|
+
&& bytes[i + 1] == b'.'
|
|
552
|
+
&& bytes[i + 2..i + 2 + nlen].eq_ignore_ascii_case(nb)
|
|
553
|
+
&& (i + 2 + nlen == bytes.len() || !is_ident_byte(bytes[i + 2 + nlen]))
|
|
554
|
+
{
|
|
555
|
+
out.extend_from_slice(b"$.");
|
|
556
|
+
out.extend_from_slice(replacement.as_bytes());
|
|
557
|
+
i += 2 + nlen;
|
|
558
|
+
} else {
|
|
559
|
+
out.push(bytes[i]);
|
|
560
|
+
i += 1;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
String::from_utf8(out).unwrap()
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/// Substitute component props into a template — the compiled twin of the
|
|
567
|
+
/// runtime's renderPropsAndSlot (runtime/component.js). Both sides must
|
|
568
|
+
/// transform identically:
|
|
569
|
+
/// - exact `@[propName]` bindings (case-insensitive)
|
|
570
|
+
/// - prop identifiers inside other `@[expr]` bindings
|
|
571
|
+
/// - prop identifiers inside directive comments (`if` / `else if` / `each`)
|
|
572
|
+
/// - `$.propName` references inside event-handler attribute bodies
|
|
573
|
+
/// Binding props (`prop="@[path]"`) rewrite identifiers to the bound path;
|
|
574
|
+
/// literal props inject the value (bare boolean attrs → true, strings
|
|
575
|
+
/// JSON-quoted, numerics raw).
|
|
576
|
+
fn substitute_props(template: &str, props: &[(String, String)]) -> String {
|
|
577
|
+
let binding_re = regex::Regex::new(r"@\[([^\]]+)\]").unwrap();
|
|
578
|
+
let directive_re = regex::Regex::new(r"(?s)<!--\s*(if|else if|each)\s+(.*?)\s*-->").unwrap();
|
|
579
|
+
let event_re = regex::Regex::new(r#"\bon(\w+)="([^"]*)""#).unwrap();
|
|
580
|
+
let numeric_re = regex::Regex::new(r"(?i)^-?\d+(\.\d+)?(e[+-]?\d+)?$").unwrap();
|
|
581
|
+
|
|
582
|
+
let mut html = template.to_string();
|
|
583
|
+
|
|
584
|
+
for (prop_name, prop_value) in props {
|
|
585
|
+
let exact_re =
|
|
586
|
+
regex::Regex::new(&format!(r"(?i)@\[{}\]", regex::escape(prop_name))).unwrap();
|
|
587
|
+
|
|
588
|
+
let binding_path = prop_value
|
|
589
|
+
.strip_prefix("@[")
|
|
590
|
+
.and_then(|v| v.strip_suffix(']'));
|
|
591
|
+
|
|
592
|
+
let (exact_repl, ident_repl, each_repl, state_repl) = if let Some(path) = binding_path {
|
|
593
|
+
(
|
|
594
|
+
format!("@[{}]", path),
|
|
595
|
+
format!("({})", path),
|
|
596
|
+
path.to_string(),
|
|
597
|
+
path.to_string(),
|
|
598
|
+
)
|
|
599
|
+
} else {
|
|
600
|
+
let literal = if prop_value.is_empty() {
|
|
601
|
+
"true".to_string()
|
|
602
|
+
} else if numeric_re.is_match(prop_value) {
|
|
603
|
+
prop_value.clone()
|
|
604
|
+
} else {
|
|
605
|
+
serde_json::to_string(prop_value).unwrap()
|
|
606
|
+
};
|
|
607
|
+
(prop_value.clone(), literal.clone(), literal.clone(), literal)
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
html = exact_re
|
|
611
|
+
.replace_all(&html, regex::NoExpand(exact_repl.as_str()))
|
|
612
|
+
.to_string();
|
|
613
|
+
|
|
614
|
+
html = binding_re
|
|
615
|
+
.replace_all(&html, |c: ®ex::Captures| {
|
|
616
|
+
let expr = c.get(1).unwrap().as_str();
|
|
617
|
+
let rewritten = substitute_identifier(expr, prop_name, &ident_repl);
|
|
618
|
+
if rewritten == expr {
|
|
619
|
+
c.get(0).unwrap().as_str().to_string()
|
|
620
|
+
} else {
|
|
621
|
+
format!("@[{}]", rewritten)
|
|
622
|
+
}
|
|
623
|
+
})
|
|
624
|
+
.to_string();
|
|
625
|
+
|
|
626
|
+
html = directive_re
|
|
627
|
+
.replace_all(&html, |c: ®ex::Captures| {
|
|
628
|
+
let kw = c.get(1).unwrap().as_str();
|
|
629
|
+
let expr = c.get(2).unwrap().as_str();
|
|
630
|
+
let repl = if kw == "each" { each_repl.as_str() } else { ident_repl.as_str() };
|
|
631
|
+
let rewritten = substitute_identifier(expr, prop_name, repl);
|
|
632
|
+
if rewritten == expr {
|
|
633
|
+
c.get(0).unwrap().as_str().to_string()
|
|
634
|
+
} else {
|
|
635
|
+
format!("<!-- {} {} -->", kw, rewritten)
|
|
636
|
+
}
|
|
637
|
+
})
|
|
638
|
+
.to_string();
|
|
639
|
+
|
|
640
|
+
html = event_re
|
|
641
|
+
.replace_all(&html, |c: ®ex::Captures| {
|
|
642
|
+
let ev = c.get(1).unwrap().as_str();
|
|
643
|
+
let body = c.get(2).unwrap().as_str();
|
|
644
|
+
let rewritten = substitute_state_ref(body, prop_name, &state_repl);
|
|
645
|
+
if rewritten == body {
|
|
646
|
+
c.get(0).unwrap().as_str().to_string()
|
|
647
|
+
} else {
|
|
648
|
+
format!(r#"on{}="{}""#, ev, rewritten)
|
|
649
|
+
}
|
|
650
|
+
})
|
|
651
|
+
.to_string();
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
html
|
|
655
|
+
}
|
|
656
|
+
|
|
494
657
|
/// Transform custom HTML elements to divs with classes
|
|
495
658
|
fn transform_custom_tags_to_divs(content: &str, reserved_elements: &[String]) -> String {
|
|
496
659
|
let mut result = content.to_string();
|
package/package.json
CHANGED
package/runtime/cleanup.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { debugLog } from './debug.js';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
PHASE_READY,
|
|
4
|
+
FOUC_CLASS_OR_ATTR,
|
|
5
|
+
DEHYDRATE_CLASS_OR_ATTR,
|
|
6
|
+
NON_REACTIVE_ELEMENTS,
|
|
7
|
+
} from './constants.js';
|
|
3
8
|
|
|
4
9
|
/**
|
|
5
10
|
* Check if all Vibe processing is complete and cleanup can run
|
|
@@ -14,12 +19,16 @@ export const shouldCleanup = (rootElement) => {
|
|
|
14
19
|
return false;
|
|
15
20
|
}
|
|
16
21
|
|
|
17
|
-
// 2. Check for unhydrated bindings (literal @[...] in DOM, excluding dehydrated
|
|
22
|
+
// 2. Check for unhydrated bindings (literal @[...] in DOM, excluding dehydrated
|
|
23
|
+
// and non-reactive elements — hydrate never touches those, so a literal inside
|
|
24
|
+
// them is final content, not pending work)
|
|
18
25
|
const walker = document.createTreeWalker(rootElement, NodeFilter.SHOW_TEXT, {
|
|
19
26
|
acceptNode(node) {
|
|
20
|
-
// Check if this text node is inside a dehydrated element
|
|
21
27
|
let parent = node.parentElement;
|
|
22
28
|
while (parent && parent !== rootElement) {
|
|
29
|
+
if (NON_REACTIVE_ELEMENTS.includes(parent.nodeName)) {
|
|
30
|
+
return NodeFilter.FILTER_REJECT;
|
|
31
|
+
}
|
|
23
32
|
if (parent.hasAttribute(DEHYDRATE_CLASS_OR_ATTR) || parent.classList?.contains(DEHYDRATE_CLASS_OR_ATTR)) {
|
|
24
33
|
return NodeFilter.FILTER_REJECT; // Skip dehydrated content
|
|
25
34
|
}
|
package/runtime/component.js
CHANGED
|
@@ -98,9 +98,131 @@ export const collectComponentIds = (node, into = new Set()) => {
|
|
|
98
98
|
return into;
|
|
99
99
|
};
|
|
100
100
|
|
|
101
|
+
// Prepare a component <script> body for execution through the injected
|
|
102
|
+
// component() path: strip the `import component from '...'` line (the
|
|
103
|
+
// function is passed in as a parameter) and rewrite any remaining static
|
|
104
|
+
// imports to awaited dynamic imports. Shared by the fetch path
|
|
105
|
+
// (processSingle) and the compiled-page path (executeCompiledComponentScripts).
|
|
106
|
+
const transformScriptContent = (rawContent) => {
|
|
107
|
+
// Strip `import component from '...'` — Vibe injects the contextual
|
|
108
|
+
// component() function as a parameter (it needs access to the temp DOM)
|
|
109
|
+
let content = rawContent.replace(
|
|
110
|
+
/import\s+component\s+from\s+['"][^'"]+['"];?\s*/g,
|
|
111
|
+
''
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// Check for remaining imports that need rewriting
|
|
115
|
+
const hasImports = /import\s/.test(content);
|
|
116
|
+
|
|
117
|
+
if (hasImports) {
|
|
118
|
+
// Rewrite remaining imports to dynamic await import()
|
|
119
|
+
// Order matters: combined → default → named → namespace → side-effect (most specific first)
|
|
120
|
+
content = content.replace(
|
|
121
|
+
/import\s+(\w+)\s*,\s*\{([^}]+)\}\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
122
|
+
'const __m_$1 = await import($3); const $1 = __m_$1.default; const {$2} = __m_$1;'
|
|
123
|
+
);
|
|
124
|
+
content = content.replace(
|
|
125
|
+
/import\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
126
|
+
'const $1 = (await import($2)).default;'
|
|
127
|
+
);
|
|
128
|
+
content = content.replace(
|
|
129
|
+
/import\s+\{([^}]+)\}\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
130
|
+
'const {$1} = await import($2);'
|
|
131
|
+
);
|
|
132
|
+
content = content.replace(
|
|
133
|
+
/import\s+\*\s+as\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
134
|
+
'const $1 = await import($2);'
|
|
135
|
+
);
|
|
136
|
+
content = content.replace(
|
|
137
|
+
/import\s+(['"][^'"]+['"])\s*;?/g,
|
|
138
|
+
'await import($1);'
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { content, hasImports };
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Compiled pages inline `<component src>` content at build time, and the
|
|
146
|
+
// compiler neuters each component script to type="vibe-module" so the browser
|
|
147
|
+
// does NOT execute it as a native page module — native timing is wrong
|
|
148
|
+
// (pre-boot: `$` is the placeholder, state registered after boot never merges
|
|
149
|
+
// into the live proxy). This executes those scripts through the same
|
|
150
|
+
// injected-component() path processSingle uses for fetched scripts: one
|
|
151
|
+
// pipeline, identical semantics in both modes. Scripts stay in the DOM
|
|
152
|
+
// (inert) so the manifest's childNodes indices keep matching the page.
|
|
153
|
+
//
|
|
154
|
+
// Returns a Promise when any script is async (has imports) — the caller
|
|
155
|
+
// gates `ready` on it — or null when everything ran synchronously.
|
|
156
|
+
export const executeCompiledComponentScripts = () => {
|
|
157
|
+
const scripts = document.querySelectorAll('script[type="vibe-module"]');
|
|
158
|
+
if (!scripts.length) return null;
|
|
159
|
+
|
|
160
|
+
// Build-time tagging already assigned _cN ids to wrappers; advance the
|
|
161
|
+
// runtime counter past them so freshly generated ids never collide.
|
|
162
|
+
document.querySelectorAll('[data-vibe-component-id]').forEach((el) => {
|
|
163
|
+
const m = el.getAttribute('data-vibe-component-id')?.match(/^_c(\d+)$/);
|
|
164
|
+
if (m) componentCounter = Math.max(componentCounter, Number(m[1]) + 1);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const asyncTasks = [];
|
|
168
|
+
const claimed = new Set();
|
|
169
|
+
|
|
170
|
+
for (const script of scripts) {
|
|
171
|
+
// Parity with the fetch path: dehydrated components never execute
|
|
172
|
+
if (script.closest(`[${DEHYDRATE_CLASS_OR_ATTR}], .${DEHYDRATE_CLASS_OR_ATTR}`)) continue;
|
|
173
|
+
|
|
174
|
+
const rawContent = script.textContent?.trim() || '';
|
|
175
|
+
if (!rawContent) continue;
|
|
176
|
+
|
|
177
|
+
// The build tagged each component wrapper with its deterministic id —
|
|
178
|
+
// the same id the stamped bindings reference. First script in a wrapper
|
|
179
|
+
// claims it; additional scripts get fresh ids (mirrors the per-script
|
|
180
|
+
// ids of the fetch path).
|
|
181
|
+
const wrapper = script.closest('[data-vibe-component-id]');
|
|
182
|
+
let componentId = wrapper?.getAttribute('data-vibe-component-id');
|
|
183
|
+
if (!componentId || claimed.has(componentId)) componentId = generateComponentId();
|
|
184
|
+
claimed.add(componentId);
|
|
185
|
+
|
|
186
|
+
const { content, hasImports } = transformScriptContent(rawContent);
|
|
187
|
+
|
|
188
|
+
const componentFn = (state) => {
|
|
189
|
+
if (!window.__vibeComponents) window.__vibeComponents = {};
|
|
190
|
+
window.__vibeComponents[componentId] = state;
|
|
191
|
+
if (window.$) window.$[componentId] = state;
|
|
192
|
+
return componentId;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
runComponentCleanups(componentId);
|
|
196
|
+
const scopedDollar = createScopedDollar(componentId);
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
if (hasImports) {
|
|
200
|
+
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
|
201
|
+
asyncTasks.push(new AsyncFunction('$', 'component', content)(scopedDollar, componentFn));
|
|
202
|
+
} else {
|
|
203
|
+
new Function('$', 'component', content)(scopedDollar, componentFn);
|
|
204
|
+
}
|
|
205
|
+
} catch (e) {
|
|
206
|
+
console.warn('[vibe] Failed to execute component script:', e);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return asyncTasks.length ? Promise.all(asyncTasks) : null;
|
|
211
|
+
};
|
|
212
|
+
|
|
101
213
|
// Track pending fetches to cancel them if element is removed
|
|
102
214
|
const pendingFetches = new WeakMap(); // element → AbortController
|
|
103
215
|
|
|
216
|
+
// Component HTML is parsed (and prop/slot-transformed) while its @[...]
|
|
217
|
+
// bindings are still literal text. Parsing in the live document lets the
|
|
218
|
+
// browser act on those literals mid-parse — Chrome logs "The specified value
|
|
219
|
+
// ... cannot be parsed" for a typed input's value="@[...]". An inert document
|
|
220
|
+
// (no browsing context) parses identical DOM without a console to complain to;
|
|
221
|
+
// nodes are auto-adopted into the live document on insertion.
|
|
222
|
+
let inertDocument;
|
|
223
|
+
const createDetached = (tagName) =>
|
|
224
|
+
(inertDocument ??= document.implementation.createHTMLDocument('')).createElement(tagName);
|
|
225
|
+
|
|
104
226
|
// Cancel a pending fetch for a component element
|
|
105
227
|
export const abortComponentFetch = (element) => {
|
|
106
228
|
const controller = pendingFetches.get(element);
|
|
@@ -273,7 +395,7 @@ const renderPropsAndSlot = (temp, props, slotHtml) => {
|
|
|
273
395
|
// or trigger a full re-mount (new componentIds).
|
|
274
396
|
export const renderComponentTemplate = (rawHtml, props = {}, slotHtml = '', options = {}) => {
|
|
275
397
|
const { componentIds = [] } = options;
|
|
276
|
-
const temp =
|
|
398
|
+
const temp = createDetached('div');
|
|
277
399
|
temp.innerHTML = rawHtml;
|
|
278
400
|
|
|
279
401
|
const idsToReuse = [...componentIds];
|
|
@@ -318,7 +440,7 @@ const processSingle = (el, debug) => {
|
|
|
318
440
|
.then((r) => r.text())
|
|
319
441
|
.then((html) => {
|
|
320
442
|
// Parse HTML in temporary container to process component scripts
|
|
321
|
-
const temp =
|
|
443
|
+
const temp = createDetached('div');
|
|
322
444
|
temp.innerHTML = html;
|
|
323
445
|
|
|
324
446
|
// Process any <script type="module"> elements
|
|
@@ -343,43 +465,10 @@ const processSingle = (el, debug) => {
|
|
|
343
465
|
let firstComponentId = null;
|
|
344
466
|
|
|
345
467
|
for (const script of moduleScripts) {
|
|
346
|
-
|
|
347
|
-
if (!
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
// component() function as a parameter (it needs access to the temp DOM)
|
|
351
|
-
scriptContent = scriptContent.replace(
|
|
352
|
-
/import\s+component\s+from\s+['"][^'"]+['"];?\s*/g,
|
|
353
|
-
''
|
|
354
|
-
);
|
|
355
|
-
|
|
356
|
-
// Check for remaining imports that need rewriting
|
|
357
|
-
const hasImports = /import\s/.test(scriptContent);
|
|
358
|
-
|
|
359
|
-
if (hasImports) {
|
|
360
|
-
// Rewrite remaining imports to dynamic await import()
|
|
361
|
-
// Order matters: combined → default → named → namespace → side-effect (most specific first)
|
|
362
|
-
scriptContent = scriptContent.replace(
|
|
363
|
-
/import\s+(\w+)\s*,\s*\{([^}]+)\}\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
364
|
-
'const __m_$1 = await import($3); const $1 = __m_$1.default; const {$2} = __m_$1;'
|
|
365
|
-
);
|
|
366
|
-
scriptContent = scriptContent.replace(
|
|
367
|
-
/import\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
368
|
-
'const $1 = (await import($2)).default;'
|
|
369
|
-
);
|
|
370
|
-
scriptContent = scriptContent.replace(
|
|
371
|
-
/import\s+\{([^}]+)\}\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
372
|
-
'const {$1} = await import($2);'
|
|
373
|
-
);
|
|
374
|
-
scriptContent = scriptContent.replace(
|
|
375
|
-
/import\s+\*\s+as\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
376
|
-
'const $1 = await import($2);'
|
|
377
|
-
);
|
|
378
|
-
scriptContent = scriptContent.replace(
|
|
379
|
-
/import\s+(['"][^'"]+['"])\s*;?/g,
|
|
380
|
-
'await import($1);'
|
|
381
|
-
);
|
|
382
|
-
}
|
|
468
|
+
const rawContent = script.textContent?.trim() || '';
|
|
469
|
+
if (!rawContent) continue;
|
|
470
|
+
|
|
471
|
+
const { content: scriptContent, hasImports } = transformScriptContent(rawContent);
|
|
383
472
|
|
|
384
473
|
// Reuse component ID from HMR if available, otherwise generate new
|
|
385
474
|
const reuseIds = el._vibeReuseComponentIds;
|
|
@@ -454,9 +543,7 @@ const processSingle = (el, debug) => {
|
|
|
454
543
|
if (el.parentNode) {
|
|
455
544
|
// Create clean wrapper element (preserve tag type: component or div.component)
|
|
456
545
|
const newWrapper =
|
|
457
|
-
el.tagName === 'DIV'
|
|
458
|
-
? document.createElement('div')
|
|
459
|
-
: document.createElement('component');
|
|
546
|
+
el.tagName === 'DIV' ? createDetached('div') : createDetached('component');
|
|
460
547
|
|
|
461
548
|
if (el.tagName === 'DIV') {
|
|
462
549
|
newWrapper.className = 'component';
|
|
@@ -476,7 +563,7 @@ const processSingle = (el, debug) => {
|
|
|
476
563
|
// detached `<component src>` to the new wrapper. The detached element
|
|
477
564
|
// would otherwise trigger releaseOrphanedIterationProps and free the
|
|
478
565
|
// registry slots that the inlined template's bindings still reference,
|
|
479
|
-
// causing every `@[window.
|
|
566
|
+
// causing every `@[window.__vibeiterprops._pN]` to resolve to undefined
|
|
480
567
|
// on the next hydrate.
|
|
481
568
|
if (el._vibeIterPropIds) {
|
|
482
569
|
newWrapper._vibeIterPropIds = el._vibeIterPropIds;
|
package/runtime/conditionals.js
CHANGED
|
@@ -263,18 +263,25 @@ const unmountBranch = (node, manifest) => {
|
|
|
263
263
|
}
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
-
//
|
|
266
|
+
// The conditional owns everything between its comments. That's more than
|
|
267
|
+
// activeInstance.nodes: nested directives at the branch's top level (each
|
|
268
|
+
// rows, deeper if branches) insert their rendered output between their own
|
|
269
|
+
// comments after the branch mounts, so it never appears in the original
|
|
270
|
+
// clonedNodes list. Sweep the live range (same pattern as iteration
|
|
271
|
+
// teardown), collecting componentIds BEFORE detaching so we can check
|
|
267
272
|
// after removal whether any live DOM still holds them.
|
|
273
|
+
const { startComment, endComment } = node.meta;
|
|
268
274
|
const ids = new Set();
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
branchNodeRegistry.delete(
|
|
274
|
-
if (
|
|
275
|
-
|
|
275
|
+
let current = startComment.nextSibling;
|
|
276
|
+
while (current && current !== endComment) {
|
|
277
|
+
const next = current.nextSibling;
|
|
278
|
+
collectComponentIds(current, ids);
|
|
279
|
+
branchNodeRegistry.delete(current);
|
|
280
|
+
if (current.parentNode) {
|
|
281
|
+
current.parentNode.removeChild(current);
|
|
276
282
|
}
|
|
277
|
-
|
|
283
|
+
current = next;
|
|
284
|
+
}
|
|
278
285
|
|
|
279
286
|
// CLEANUP OF CURRENT STATE
|
|
280
287
|
releaseOrphanedComponentState(ids);
|
package/runtime/hydrate.js
CHANGED
|
@@ -92,9 +92,18 @@ export default (affected, state, manifest = {}, oldState = {}) => {
|
|
|
92
92
|
const expr = isPureBinding[1];
|
|
93
93
|
const value = evalInScope(expr, effectiveState, element);
|
|
94
94
|
if (element[attrName] !== value) element[attrName] = value;
|
|
95
|
-
if (
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
if (attrName === 'value') {
|
|
96
|
+
if (value !== undefined && value !== null) {
|
|
97
|
+
const str = String(value);
|
|
98
|
+
if (element.getAttribute(attrName) !== str) element.setAttribute(attrName, str);
|
|
99
|
+
}
|
|
100
|
+
} else if (value) {
|
|
101
|
+
// checked/selected are boolean — the truthful attribute form is
|
|
102
|
+
// presence (empty) when truthy, absence when falsy. Stringifying
|
|
103
|
+
// would leave checked="false", which is "checked" to CSS and HTML.
|
|
104
|
+
if (element.getAttribute(attrName) !== '') element.setAttribute(attrName, '');
|
|
105
|
+
} else if (element.hasAttribute(attrName)) {
|
|
106
|
+
element.removeAttribute(attrName);
|
|
98
107
|
}
|
|
99
108
|
} else if (!isValueAttr && isPureBinding) {
|
|
100
109
|
// Boolean-like attributes: add or remove based on truthiness.
|