@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
|
@@ -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,
|
|
@@ -479,17 +488,28 @@ impl Compiler {
|
|
|
479
488
|
}
|
|
480
489
|
}
|
|
481
490
|
|
|
482
|
-
// Validate HTML syntax
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
491
|
+
// Validate HTML syntax (always runs)
|
|
492
|
+
let validation_start = Instant::now();
|
|
493
|
+
if self.verbose {
|
|
494
|
+
println!("\nValidating HTML syntax...");
|
|
495
|
+
}
|
|
496
|
+
self.validate_html_syntax()?;
|
|
497
|
+
stats.validation_time_ms = Some(validation_start.elapsed().as_secs_f64() * 1000.0);
|
|
498
|
+
if self.verbose {
|
|
499
|
+
println!(" HTML validation completed successfully");
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Clean output directory if not in no-clean mode
|
|
503
|
+
if !self.config.no_clean && self.config.output.exists() {
|
|
490
504
|
if self.verbose {
|
|
491
|
-
println!("
|
|
505
|
+
println!(" Cleaning output directory: {}", self.config.output.display());
|
|
492
506
|
}
|
|
507
|
+
fs::remove_dir_all(&self.config.output).map_err(|e| {
|
|
508
|
+
CompileError::WriteError {
|
|
509
|
+
path: self.config.output.display().to_string(),
|
|
510
|
+
source: e,
|
|
511
|
+
}
|
|
512
|
+
})?;
|
|
493
513
|
}
|
|
494
514
|
|
|
495
515
|
// Create output directory
|
|
@@ -622,6 +642,13 @@ impl Compiler {
|
|
|
622
642
|
}
|
|
623
643
|
|
|
624
644
|
for file_path in files {
|
|
645
|
+
// Validate HTML before compiling (same as full compile path)
|
|
646
|
+
let content = fs::read_to_string(file_path).map_err(|e| CompileError::ReadError {
|
|
647
|
+
path: file_path.display().to_string(),
|
|
648
|
+
source: e,
|
|
649
|
+
})?;
|
|
650
|
+
self.validate_html(&content, file_path)?;
|
|
651
|
+
|
|
625
652
|
// Calculate relative path
|
|
626
653
|
let relative_path = file_path
|
|
627
654
|
.strip_prefix(&self.config.source)
|
|
@@ -785,30 +812,19 @@ impl Compiler {
|
|
|
785
812
|
source_root: &Path,
|
|
786
813
|
) -> Result<(), String> {
|
|
787
814
|
use crate::compiler::manifest_builder::ManifestBuilder;
|
|
788
|
-
use crate::compiler::value_stamper::ValueStamper;
|
|
789
815
|
use crate::compiler::component_tagger::ComponentTagger;
|
|
816
|
+
use crate::compiler::value_stamper::ValueStamper;
|
|
790
817
|
|
|
791
818
|
// Tag components with deterministic IDs and structure state
|
|
792
819
|
let tagged = ComponentTagger::tag_components(html, &source_root.to_path_buf())?;
|
|
793
820
|
let html = &tagged.html; // Use modified HTML with data-vibe-component-id attributes
|
|
794
821
|
let state = tagged.state;
|
|
795
822
|
|
|
796
|
-
// Build manifest from
|
|
823
|
+
// Build manifest from pre-stamp HTML (both conditional branches present, @[...] markers intact)
|
|
824
|
+
// This ensures restoration.template has both branches so runtime can switch between them
|
|
797
825
|
let manifest_builder = ManifestBuilder::new();
|
|
798
826
|
let manifest = manifest_builder.build_from_html(html, &state, iterations_as_is)?;
|
|
799
827
|
|
|
800
|
-
// Stamp values (pre-render) AFTER building manifest
|
|
801
|
-
// When components_as_is is true, skip stamping inside component elements (runtime will handle them)
|
|
802
|
-
let stamper = ValueStamper::new(&state, components_as_is)?;
|
|
803
|
-
let mut pre_rendered = stamper.stamp_html(html.to_string())?;
|
|
804
|
-
|
|
805
|
-
// Remove FOUC prevention since HTML is pre-rendered
|
|
806
|
-
pre_rendered = remove_fouc_prevention(pre_rendered);
|
|
807
|
-
|
|
808
|
-
// Write pre-rendered HTML
|
|
809
|
-
fs::write(html_path, pre_rendered)
|
|
810
|
-
.map_err(|e| format!("Failed to write HTML: {}", e))?;
|
|
811
|
-
|
|
812
828
|
// Write manifest
|
|
813
829
|
let manifest_path = output_dir
|
|
814
830
|
.join("vibe-hyperspeed")
|
|
@@ -832,6 +848,20 @@ impl Compiler {
|
|
|
832
848
|
fs::write(&manifest_path, manifest_js)
|
|
833
849
|
.map_err(|e| format!("Failed to write manifest: {}", e))?;
|
|
834
850
|
|
|
851
|
+
// Stamp the compiled HTML with initial state values for FOUC prevention:
|
|
852
|
+
// - @[...] bindings replaced with their initial values
|
|
853
|
+
// - Only the active conditional branch is kept (inactive branch stripped)
|
|
854
|
+
// - Iterations are pre-rendered with initial array data
|
|
855
|
+
// The manifest (written above) preserves BOTH branches so the runtime can
|
|
856
|
+
// restore them and switch between branches reactively.
|
|
857
|
+
let stamper = ValueStamper::new(&state, components_as_is)
|
|
858
|
+
.map_err(|e| format!("Failed to create value stamper: {}", e))?;
|
|
859
|
+
let stamped = stamper.stamp_html(html.to_string())
|
|
860
|
+
.map_err(|e| format!("Failed to stamp HTML: {}", e))?;
|
|
861
|
+
|
|
862
|
+
fs::write(html_path, stamped)
|
|
863
|
+
.map_err(|e| format!("Failed to write stamped HTML: {}", e))?;
|
|
864
|
+
|
|
835
865
|
Ok(())
|
|
836
866
|
}
|
|
837
867
|
|
|
@@ -1076,11 +1106,6 @@ impl Compiler {
|
|
|
1076
1106
|
source: e,
|
|
1077
1107
|
})?;
|
|
1078
1108
|
|
|
1079
|
-
// Validate if requested
|
|
1080
|
-
if self.config.validate {
|
|
1081
|
-
self.validate_html(&content, path)?;
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
1109
|
// Count <component src="..."> instances before processing (only if not components_as_is)
|
|
1085
1110
|
let (internal_count, external_count, component_srcs) = if !self.config.components_as_is {
|
|
1086
1111
|
self.extract_components(&content)
|
|
@@ -1099,12 +1124,17 @@ impl Compiler {
|
|
|
1099
1124
|
);
|
|
1100
1125
|
|
|
1101
1126
|
// Minify if requested
|
|
1102
|
-
let output = if self.config.minify {
|
|
1127
|
+
let mut output = if self.config.minify {
|
|
1103
1128
|
minify_html(&processed)
|
|
1104
1129
|
} else {
|
|
1105
1130
|
processed
|
|
1106
1131
|
};
|
|
1107
1132
|
|
|
1133
|
+
// Remove FOUC prevention unless fouc_as_is is enabled
|
|
1134
|
+
if !self.config.fouc_as_is {
|
|
1135
|
+
output = remove_fouc_prevention(output);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1108
1138
|
// Write to output
|
|
1109
1139
|
let output_path = self.get_output_path(path, relative_path)?;
|
|
1110
1140
|
fs::write(&output_path, output).map_err(|e| CompileError::WriteError {
|
|
@@ -1212,8 +1242,8 @@ impl Compiler {
|
|
|
1212
1242
|
// Transform custom tags to <component> tags
|
|
1213
1243
|
let transformed = parser.process_html(
|
|
1214
1244
|
&content,
|
|
1215
|
-
|
|
1216
|
-
&
|
|
1245
|
+
self.config.elements_as_is,
|
|
1246
|
+
&self.config.reserved_elements,
|
|
1217
1247
|
true, // components_as_is (don't inline, just transform)
|
|
1218
1248
|
&self.config.components,
|
|
1219
1249
|
);
|
|
@@ -1249,8 +1279,8 @@ impl Compiler {
|
|
|
1249
1279
|
// Transform custom tags to <component> tags
|
|
1250
1280
|
let transformed = parser.process_html(
|
|
1251
1281
|
&content,
|
|
1252
|
-
|
|
1253
|
-
&
|
|
1282
|
+
self.config.elements_as_is,
|
|
1283
|
+
&self.config.reserved_elements,
|
|
1254
1284
|
true, // components_as_is (don't inline, just transform)
|
|
1255
1285
|
&self.config.components,
|
|
1256
1286
|
);
|
|
@@ -1463,8 +1493,8 @@ impl Compiler {
|
|
|
1463
1493
|
let file_name = entry.file_name();
|
|
1464
1494
|
let file_name_str = file_name.to_string_lossy();
|
|
1465
1495
|
|
|
1466
|
-
// Skip
|
|
1467
|
-
if
|
|
1496
|
+
// Skip specific directories/patterns (includes dotfiles via SKIP_FILES)
|
|
1497
|
+
if should_skip_path(&path, &file_name_str) {
|
|
1468
1498
|
continue;
|
|
1469
1499
|
}
|
|
1470
1500
|
|
|
@@ -1505,8 +1535,8 @@ impl Compiler {
|
|
|
1505
1535
|
}
|
|
1506
1536
|
|
|
1507
1537
|
fn validate_html(&self, content: &str, path: &Path) -> Result<(), CompileError> {
|
|
1538
|
+
// Check 1: Quote balance per line
|
|
1508
1539
|
let mut line_num = 1;
|
|
1509
|
-
|
|
1510
1540
|
for line in content.lines() {
|
|
1511
1541
|
let quote_count = line.matches('"').count();
|
|
1512
1542
|
if quote_count % 2 != 0 {
|
|
@@ -1519,9 +1549,77 @@ impl Compiler {
|
|
|
1519
1549
|
line_num += 1;
|
|
1520
1550
|
}
|
|
1521
1551
|
|
|
1552
|
+
// Check 2: Tag balance (unclosed elements break slot extraction)
|
|
1553
|
+
self.validate_tag_balance(content, path)
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
fn validate_tag_balance(&self, content: &str, path: &Path) -> Result<(), CompileError> {
|
|
1557
|
+
const VOID_ELEMENTS: &[&str] = &[
|
|
1558
|
+
"area", "base", "br", "col", "embed", "hr", "img", "input",
|
|
1559
|
+
"link", "meta", "param", "source", "track", "wbr",
|
|
1560
|
+
];
|
|
1561
|
+
// These are implicitly closed by the parser — don't require explicit close tags
|
|
1562
|
+
const IMPLICIT_CLOSE: &[&str] = &[
|
|
1563
|
+
"html", "head", "body", "p", "li", "dt", "dd", "option",
|
|
1564
|
+
"optgroup", "tr", "td", "th", "thead", "tbody", "tfoot", "colgroup",
|
|
1565
|
+
];
|
|
1566
|
+
|
|
1567
|
+
// Strip comments, raw content blocks, and @[...] bindings before tag scanning
|
|
1568
|
+
let stripped = Self::strip_for_tag_validation(content);
|
|
1569
|
+
|
|
1570
|
+
// Match any tag: opening, self-closing, or closing
|
|
1571
|
+
let tag_re = Regex::new(r"</?([a-zA-Z][a-zA-Z0-9-]*)(?:\s[^>]*)?>").unwrap();
|
|
1572
|
+
|
|
1573
|
+
// Count opens vs closes per tag name; track line of first open for error reporting
|
|
1574
|
+
let mut counts: HashMap<String, (i32, usize)> = HashMap::new();
|
|
1575
|
+
for cap in tag_re.captures_iter(&stripped) {
|
|
1576
|
+
let full = cap.get(0).unwrap().as_str();
|
|
1577
|
+
let name = cap[1].to_lowercase();
|
|
1578
|
+
if VOID_ELEMENTS.contains(&name.as_str()) || IMPLICIT_CLOSE.contains(&name.as_str()) {
|
|
1579
|
+
continue;
|
|
1580
|
+
}
|
|
1581
|
+
if full.starts_with("</") {
|
|
1582
|
+
counts.entry(name).and_modify(|(c, _)| *c -= 1);
|
|
1583
|
+
} else if full.ends_with("/>") {
|
|
1584
|
+
// self-closing — no balance change
|
|
1585
|
+
} else {
|
|
1586
|
+
let offset = cap.get(0).unwrap().start();
|
|
1587
|
+
let line = stripped[..offset].matches('\n').count() + 1;
|
|
1588
|
+
let entry = counts.entry(name).or_insert((0, line));
|
|
1589
|
+
entry.0 += 1;
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
// Report the first unclosed tag (sorted by line for deterministic output)
|
|
1594
|
+
let mut unclosed: Vec<(String, usize)> = counts.into_iter()
|
|
1595
|
+
.filter(|(_, (count, _))| *count > 0)
|
|
1596
|
+
.map(|(name, (_, line))| (name, line))
|
|
1597
|
+
.collect();
|
|
1598
|
+
unclosed.sort_by_key(|(_, line)| *line);
|
|
1599
|
+
|
|
1600
|
+
if let Some((name, line)) = unclosed.first() {
|
|
1601
|
+
return Err(CompileError::ValidationError {
|
|
1602
|
+
file: path.display().to_string(),
|
|
1603
|
+
line: *line,
|
|
1604
|
+
message: format!("Unclosed tag <{}>", name),
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1522
1608
|
Ok(())
|
|
1523
1609
|
}
|
|
1524
1610
|
|
|
1611
|
+
fn strip_for_tag_validation(content: &str) -> String {
|
|
1612
|
+
// Strip HTML comments <!-- ... --> (includes Vibe syntax: each, if, else, /if, /each)
|
|
1613
|
+
let comment_re = Regex::new(r"(?s)<!--.*?-->").unwrap();
|
|
1614
|
+
let s = comment_re.replace_all(content, "");
|
|
1615
|
+
// Strip @[...] reactive bindings (can contain < and > characters)
|
|
1616
|
+
let binding_re = Regex::new(r"@\[[^\]]*\]").unwrap();
|
|
1617
|
+
let s = binding_re.replace_all(&s, "");
|
|
1618
|
+
// Strip content inside <script>, <style>, <pre> to avoid parsing embedded code as HTML
|
|
1619
|
+
let raw_re = Regex::new(r"(?si)(<(?:script|style|pre)(?:\s[^>]*)?>).*?(</(?:script|style|pre)>)").unwrap();
|
|
1620
|
+
raw_re.replace_all(&s, "$1$2").into_owned()
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1525
1623
|
/// Copy an entire directory recursively
|
|
1526
1624
|
fn copy_directory(
|
|
1527
1625
|
&mut self,
|
|
@@ -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
|
|
|
@@ -108,46 +127,55 @@ impl ComponentTagger {
|
|
|
108
127
|
if is_component || is_div_component {
|
|
109
128
|
drop(borrowed_attrs); // Release borrow before checking innerHTML
|
|
110
129
|
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
130
|
+
// Only extract state from DIRECT <script> children — not the full subtree.
|
|
131
|
+
// Using the full subtree caused the Layout wrapper (a <component> created by
|
|
132
|
+
// inline_component_elements that contains all nested component scripts) to be
|
|
133
|
+
// treated as a stateful component with the merged state of all its descendants.
|
|
134
|
+
// That made rewrite_this_to_component_id rewrite every @[this.xxx] in the page
|
|
135
|
+
// to @[_c0.xxx] before child components could claim their own bindings.
|
|
136
|
+
let mut direct_scripts_html = String::new();
|
|
114
137
|
for child in node.children.borrow().iter() {
|
|
115
|
-
let
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
138
|
+
if let NodeData::Element { name: ref child_name, .. } = child.data {
|
|
139
|
+
if child_name.local.as_ref() == "script" {
|
|
140
|
+
let mut script_bytes = Vec::new();
|
|
141
|
+
let _ = serialize(
|
|
142
|
+
&mut script_bytes,
|
|
143
|
+
&SerializableHandle::from(child.clone()),
|
|
144
|
+
SerializeOpts::default()
|
|
145
|
+
);
|
|
146
|
+
if let Ok(script_str) = String::from_utf8(script_bytes) {
|
|
147
|
+
direct_scripts_html.push_str(&script_str);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
120
151
|
}
|
|
121
152
|
|
|
122
|
-
if let Ok(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
component_states.borrow_mut().push((component_id, Value::Object(comp_state)));
|
|
150
|
-
}
|
|
153
|
+
if let Ok(Value::Object(comp_state)) = StateExtractor::extract_from_html(
|
|
154
|
+
&direct_scripts_html,
|
|
155
|
+
&PathBuf::from(".")
|
|
156
|
+
) {
|
|
157
|
+
// Only tag and register components that have state
|
|
158
|
+
if !comp_state.is_empty() {
|
|
159
|
+
let component_id = format!("_c{}", *counter.borrow());
|
|
160
|
+
*counter.borrow_mut() += 1;
|
|
161
|
+
|
|
162
|
+
// Add data-vibe-component-id attribute
|
|
163
|
+
let mut attrs_mut = attrs.borrow_mut();
|
|
164
|
+
attrs_mut.push(markup5ever::Attribute {
|
|
165
|
+
name: QualName::new(
|
|
166
|
+
None,
|
|
167
|
+
Namespace::from(""),
|
|
168
|
+
LocalName::from("data-vibe-component-id"),
|
|
169
|
+
),
|
|
170
|
+
value: component_id.clone().into(),
|
|
171
|
+
});
|
|
172
|
+
drop(attrs_mut);
|
|
173
|
+
|
|
174
|
+
// Rewrite this.property to componentId.property in the node's children
|
|
175
|
+
// This allows ValueStamper to properly evaluate component-scoped expressions
|
|
176
|
+
Self::rewrite_this_to_component_id(node, &component_id);
|
|
177
|
+
|
|
178
|
+
component_states.borrow_mut().push((component_id, Value::Object(comp_state)));
|
|
151
179
|
}
|
|
152
180
|
}
|
|
153
181
|
}
|
|
@@ -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();
|