@ape-egg/vibe 1.3.2 → 1.6.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.
@@ -6,6 +6,8 @@ use std::collections::{BTreeMap, HashSet, HashMap};
6
6
  use thiserror::Error;
7
7
  use colored::Colorize;
8
8
  use regex::Regex;
9
+ use glob::Pattern;
10
+ use rayon::prelude::*;
9
11
 
10
12
  use crate::config::Config;
11
13
  use crate::parser::HtmlParser;
@@ -18,6 +20,10 @@ use crate::parser::HtmlParser;
18
20
  // Future refactoring: Replace with configurable include/exclude patterns,
19
21
  // or a more sophisticated asset pipeline.
20
22
  // =============================================================================
23
+ // FOUC prevention class/attribute name (matches runtime/constants.js)
24
+ // Can be used as class (.vibe-fouc) or attribute ([vibe-fouc])
25
+ const FOUC_CLASS_OR_ATTR: &str = "vibe-fouc";
26
+
21
27
  const MIRROR_EXTENSIONS: &[&str] = &[
22
28
  "css", "js",
23
29
  // Fonts
@@ -32,16 +38,78 @@ const MIRROR_EXTENSIONS: &[&str] = &[
32
38
  "json", "xml", "csv",
33
39
  ];
34
40
 
35
- // Directories to skip when walking source
36
- const SKIP_DIRECTORIES: &[&str] = &[
41
+ // Files and directories to skip when walking source (supports glob patterns)
42
+ // Note: Output directory is checked dynamically (not hardcoded here)
43
+ pub const SKIP_FILES: &[&str] = &[
37
44
  ".git",
38
45
  ".claude",
39
- "compiled", // Don't copy output into itself
40
- "target", // Rust build artifacts
46
+ "target", // Rust build artifacts
47
+ "tests", // Test files
41
48
  "test-results",
42
49
  "playwright-report",
50
+ "node_modules", // Skip node_modules during scanning
51
+ "**/*.test.js", // Test files
52
+ "**/*.config.js", // Config files
53
+ "**/*.test.ts", // Test files
54
+ "**/*.config.ts", // Config files
43
55
  ];
44
56
 
57
+ /// Check if a path should be skipped based on SKIP_FILES patterns
58
+ pub fn should_skip_path(path: &Path, name: &str) -> bool {
59
+ // Check exact name match (for directories and simple filenames)
60
+ if SKIP_FILES.contains(&name) {
61
+ return true;
62
+ }
63
+
64
+ // Check glob patterns (e.g., **/*.test.js)
65
+ for pattern_str in SKIP_FILES {
66
+ if pattern_str.contains('*') {
67
+ if let Ok(pattern) = Pattern::new(pattern_str) {
68
+ // Try matching against just the filename
69
+ if pattern.matches(name) {
70
+ return true;
71
+ }
72
+ // Try matching against the full path
73
+ if let Some(path_str) = path.to_str() {
74
+ if pattern.matches(path_str) {
75
+ return true;
76
+ }
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+ false
83
+ }
84
+
85
+ /// Remove FOUC prevention class and/or attribute from compiled HTML
86
+ /// Since HTML is pre-rendered, there's no need for FOUC prevention
87
+ fn remove_fouc_prevention(html: String) -> String {
88
+ let mut result = html;
89
+
90
+ // Remove as class: class="vibe-fouc" or class="vibe-fouc other-class" or class="other-class vibe-fouc"
91
+ result = Regex::new(&format!(r#"\s+class="{}""#, FOUC_CLASS_OR_ATTR))
92
+ .unwrap()
93
+ .replace_all(&result, "")
94
+ .to_string();
95
+ result = Regex::new(&format!(r#"class="{}\s+"#, FOUC_CLASS_OR_ATTR))
96
+ .unwrap()
97
+ .replace_all(&result, r#"class=""#)
98
+ .to_string();
99
+ result = Regex::new(&format!(r#"class="([^"]*\s+){}(\s+[^"]*)""#, FOUC_CLASS_OR_ATTR))
100
+ .unwrap()
101
+ .replace_all(&result, r#"class="$1$2""#)
102
+ .to_string();
103
+
104
+ // Remove as attribute: vibe-fouc or vibe-fouc=""
105
+ result = Regex::new(&format!(r#"\s+{}(?:="[^"]*")?"#, FOUC_CLASS_OR_ATTR))
106
+ .unwrap()
107
+ .replace_all(&result, "")
108
+ .to_string();
109
+
110
+ result
111
+ }
112
+
45
113
  #[derive(Error, Debug)]
46
114
  pub enum CompileError {
47
115
  #[error("Source directory not found: {0}")]
@@ -72,15 +140,11 @@ pub enum CompileError {
72
140
  CommandError(String),
73
141
  #[error("Package manager not found")]
74
142
  PackageManagerNotFound,
75
- #[error("Component validation failed")]
76
- ComponentValidationFailed(Vec<ComponentError>),
77
- }
78
-
79
- #[derive(Debug)]
80
- pub struct ComponentError {
81
- pub component_src: String,
82
- pub referenced_in: String,
83
- pub error_message: String,
143
+ #[error("Component name '{component_name}' in '{file_path}' conflicts with reserved element. Component filenames cannot match standard HTML elements (case-insensitive). Use PascalCase (e.g., 'Nav.html' instead of 'nav.html') to avoid conflicts.")]
144
+ ReservedComponentName {
145
+ component_name: String,
146
+ file_path: String,
147
+ },
84
148
  }
85
149
 
86
150
  pub struct CompileStats {
@@ -353,8 +417,8 @@ pub struct Compiler {
353
417
  verbose: bool,
354
418
  logger: Option<VerboseLogger>,
355
419
  unique_components: HashSet<String>,
356
- scanned_components: HashSet<String>,
357
- external_component_cache: std::collections::HashMap<String, String>,
420
+ /// Cache for all components (both internal paths and external URLs)
421
+ component_cache: std::collections::HashMap<String, String>,
358
422
  }
359
423
 
360
424
  impl Compiler {
@@ -365,8 +429,7 @@ impl Compiler {
365
429
  verbose,
366
430
  logger,
367
431
  unique_components: HashSet::new(),
368
- scanned_components: HashSet::new(),
369
- external_component_cache: HashMap::new(),
432
+ component_cache: HashMap::new(),
370
433
  }
371
434
  }
372
435
 
@@ -395,21 +458,37 @@ impl Compiler {
395
458
  ));
396
459
  }
397
460
 
398
- // Load components for HTML compilation (needed for validation)
461
+ // Load components for HTML compilation
399
462
  let mut parser = HtmlParser::new(self.config.components_path());
400
463
  parser.load_elements()?;
401
464
 
402
- // Validate all components upfront (before creating output directory or touching filesystem)
403
- // This happens AFTER parser.load_elements() so custom tags can be transformed to <component> tags
465
+ // Validate component names don't conflict with reserved elements
466
+ self.validate_component_names()?;
467
+
468
+ // Fetch and cache all components for inlining (when not components_as_is)
469
+ // This is separate from validation - we need component content for inlining regardless of validate flag
404
470
  if !self.config.components_as_is {
471
+ let fetch_start = Instant::now();
472
+ if self.verbose {
473
+ println!("\nFetching components for inlining...");
474
+ }
475
+ self.fetch_all_components(&parser)?;
476
+ stats.components_time_ms = fetch_start.elapsed().as_secs_f64() * 1000.0;
477
+ if self.verbose {
478
+ println!(" All components fetched successfully");
479
+ }
480
+ }
481
+
482
+ // Validate HTML syntax if requested (optional, controlled by validate flag)
483
+ if self.config.validate {
405
484
  let validation_start = Instant::now();
406
485
  if self.verbose {
407
- println!("\nValidating components...");
486
+ println!("\nValidating HTML syntax...");
408
487
  }
409
- self.validate_all_components(&parser)?;
488
+ self.validate_html_syntax()?;
410
489
  stats.validation_time_ms = Some(validation_start.elapsed().as_secs_f64() * 1000.0);
411
490
  if self.verbose {
412
- println!(" All components validated successfully");
491
+ println!(" HTML validation completed successfully");
413
492
  }
414
493
  }
415
494
 
@@ -423,15 +502,20 @@ impl Compiler {
423
502
  }
424
503
  }
425
504
 
505
+ // Canonicalize both output and source paths for reliable comparison
506
+ let canonical_output = self.config.output.canonicalize()
507
+ .unwrap_or_else(|_| self.config.output.clone());
508
+ let canonical_source = self.config.source.canonicalize()
509
+ .unwrap_or_else(|_| self.config.source.clone());
426
510
 
427
511
  // Track compile and copy times separately
428
512
  let compile_start = Instant::now();
429
513
 
430
514
  // Process all HTML files in source (excluding components directory)
431
- self.process_directory_html_only(&self.config.source.clone(), &parser, "", &mut stats)?;
515
+ self.process_directory_html_only(&canonical_source, &parser, "", &canonical_output, &canonical_source, &mut stats)?;
432
516
 
433
517
  // Process source directory for assets (CSS, JS, images, etc.)
434
- self.process_directory_assets_only(&self.config.source.clone(), "", &mut stats)?;
518
+ self.process_directory_assets_only(&canonical_source, "", &canonical_output, &canonical_source, &mut stats)?;
435
519
 
436
520
  let process_time = compile_start.elapsed();
437
521
 
@@ -505,6 +589,190 @@ impl Compiler {
505
589
  Ok(stats)
506
590
  }
507
591
 
592
+ /// Compile specific HTML files (incremental compilation for watch mode)
593
+ pub fn compile_specific_html_files(
594
+ &mut self,
595
+ files: &[PathBuf],
596
+ parser: &HtmlParser,
597
+ ) -> Result<CompileStats, CompileError> {
598
+ let mut stats = CompileStats {
599
+ files_compiled: 0,
600
+ files_copied: 0,
601
+ internal_components_unique: 0,
602
+ external_components_unique: 0,
603
+ internal_components_total: 0,
604
+ external_components_total: 0,
605
+ compile_time_ms: 0.0,
606
+ copy_time_ms: 0.0,
607
+ components_time_ms: 0.0,
608
+ validation_time_ms: None,
609
+ node_modules_time_ms: None,
610
+ package_manager: None,
611
+ node_modules_copied_as_is: false,
612
+ components_as_is: self.config.components_as_is,
613
+ };
614
+
615
+ let start = Instant::now();
616
+
617
+ // Pre-fetch components needed by these specific files (only if not components_as_is)
618
+ if !self.config.components_as_is {
619
+ let fetch_start = Instant::now();
620
+ self.fetch_components_for_files(files, parser)?;
621
+ stats.components_time_ms = fetch_start.elapsed().as_secs_f64() * 1000.0;
622
+ }
623
+
624
+ for file_path in files {
625
+ // Calculate relative path
626
+ let relative_path = file_path
627
+ .strip_prefix(&self.config.source)
628
+ .map(|p| p.parent().unwrap_or(Path::new("")))
629
+ .unwrap_or(Path::new(""))
630
+ .to_str()
631
+ .unwrap_or("");
632
+
633
+ let (internal, external, component_srcs) = self.compile_html_file(file_path, parser, relative_path)?;
634
+ stats.files_compiled += 1;
635
+ stats.internal_components_total += internal;
636
+ stats.external_components_total += external;
637
+
638
+ // Track unique components
639
+ for src in &component_srcs {
640
+ self.unique_components.insert(src.clone());
641
+ }
642
+
643
+ if let Some(ref mut logger) = self.logger {
644
+ logger.log(file_path, FileOperation::Compiled, &self.config.source);
645
+ for src in component_srcs {
646
+ logger.log_component_occurrence(src);
647
+ }
648
+ }
649
+ }
650
+
651
+ // Calculate unique components
652
+ let internal_unique = self.unique_components.iter()
653
+ .filter(|src| !src.starts_with("http://") && !src.starts_with("https://"))
654
+ .count();
655
+ let external_unique = self.unique_components.iter()
656
+ .filter(|src| src.starts_with("http://") || src.starts_with("https://"))
657
+ .count();
658
+
659
+ stats.internal_components_unique = internal_unique;
660
+ stats.external_components_unique = external_unique;
661
+
662
+ // Calculate compile time (excluding component fetch time which is already tracked)
663
+ let elapsed = start.elapsed().as_secs_f64() * 1000.0;
664
+ stats.compile_time_ms = elapsed - stats.components_time_ms;
665
+
666
+ Ok(stats)
667
+ }
668
+
669
+ /// Copy specific asset files (incremental compilation for watch mode)
670
+ pub fn copy_specific_asset_files(
671
+ &mut self,
672
+ files: &[PathBuf],
673
+ ) -> Result<CompileStats, CompileError> {
674
+ let mut stats = CompileStats {
675
+ files_compiled: 0,
676
+ files_copied: 0,
677
+ internal_components_unique: 0,
678
+ external_components_unique: 0,
679
+ internal_components_total: 0,
680
+ external_components_total: 0,
681
+ compile_time_ms: 0.0,
682
+ copy_time_ms: 0.0,
683
+ components_time_ms: 0.0,
684
+ validation_time_ms: None,
685
+ node_modules_time_ms: None,
686
+ package_manager: None,
687
+ node_modules_copied_as_is: false,
688
+ components_as_is: self.config.components_as_is,
689
+ };
690
+
691
+ let start = Instant::now();
692
+
693
+ for file_path in files {
694
+ // Calculate relative path for preserving directory structure
695
+ let relative_path = file_path
696
+ .strip_prefix(&self.config.source)
697
+ .map(|p| p.parent().unwrap_or(Path::new("")))
698
+ .unwrap_or(Path::new(""))
699
+ .to_str()
700
+ .unwrap_or("");
701
+
702
+ self.copy_file(file_path, relative_path)?;
703
+ stats.files_copied += 1;
704
+
705
+ if let Some(ref mut logger) = self.logger {
706
+ logger.log(file_path, FileOperation::Copied, &self.config.source);
707
+ }
708
+ }
709
+
710
+ let elapsed = start.elapsed().as_secs_f64() * 1000.0;
711
+ stats.copy_time_ms = elapsed;
712
+
713
+ Ok(stats)
714
+ }
715
+
716
+ /// Generate manifests for specific HTML files (incremental compilation for watch mode)
717
+ pub fn generate_manifests_for_files(&self, files: &[PathBuf]) -> Result<ManifestStats, CompileError> {
718
+ let start = Instant::now();
719
+ let mut pages_processed = 0;
720
+ let mut pages_skipped = 0;
721
+
722
+ for file_path in files {
723
+ // Convert source path to output path
724
+ let relative_path = file_path
725
+ .strip_prefix(&self.config.source)
726
+ .unwrap_or(file_path)
727
+ .to_str()
728
+ .unwrap();
729
+
730
+ let output_path = self.config.output.join(relative_path);
731
+
732
+ if !output_path.exists() {
733
+ pages_skipped += 1;
734
+ continue;
735
+ }
736
+
737
+ // Read compiled HTML from output
738
+ let html = fs::read_to_string(&output_path)
739
+ .map_err(|e| CompileError::ReadError {
740
+ path: output_path.display().to_string(),
741
+ source: e,
742
+ })?;
743
+
744
+ // Try to generate manifest for this file (skip on error)
745
+ match Self::generate_file_manifest(
746
+ &html,
747
+ &output_path,
748
+ &self.config.output,
749
+ relative_path,
750
+ self.verbose,
751
+ self.config.iterations_as_is,
752
+ self.config.components_as_is,
753
+ &self.config.source,
754
+ ) {
755
+ Ok(()) => {
756
+ pages_processed += 1;
757
+ }
758
+ Err(e) => {
759
+ pages_skipped += 1;
760
+ if self.verbose {
761
+ println!(" Skipped ({}): {}", e, relative_path);
762
+ }
763
+ }
764
+ }
765
+ }
766
+
767
+ let total_time_ms = start.elapsed().as_millis() as f64;
768
+
769
+ Ok(ManifestStats {
770
+ pages_processed,
771
+ pages_skipped,
772
+ total_time_ms,
773
+ })
774
+ }
775
+
508
776
  /// Generate manifest for a single file
509
777
  fn generate_file_manifest(
510
778
  html: &str,
@@ -512,21 +780,30 @@ impl Compiler {
512
780
  output_dir: &Path,
513
781
  relative_path: &str,
514
782
  _verbose: bool,
783
+ iterations_as_is: bool,
784
+ components_as_is: bool,
785
+ source_root: &Path,
515
786
  ) -> Result<(), String> {
516
- use crate::compiler::state_extractor::StateExtractor;
517
787
  use crate::compiler::manifest_builder::ManifestBuilder;
518
788
  use crate::compiler::value_stamper::ValueStamper;
789
+ use crate::compiler::component_tagger::ComponentTagger;
519
790
 
520
- // Extract state
521
- let state = StateExtractor::extract_from_html(html)?;
791
+ // Tag components with deterministic IDs and structure state
792
+ let tagged = ComponentTagger::tag_components(html, &source_root.to_path_buf())?;
793
+ let html = &tagged.html; // Use modified HTML with data-vibe-component-id attributes
794
+ let state = tagged.state;
522
795
 
523
- // Build manifest
796
+ // Build manifest from ORIGINAL HTML (before stamping, so templates have @[...] markers)
524
797
  let manifest_builder = ManifestBuilder::new();
525
- let manifest = manifest_builder.build_from_html(html, &state)?;
798
+ let manifest = manifest_builder.build_from_html(html, &state, iterations_as_is)?;
526
799
 
527
- // Stamp values (pre-render)
528
- let stamper = ValueStamper::new(&state);
529
- let pre_rendered = stamper.stamp_html(html.to_string())?;
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);
530
807
 
531
808
  // Write pre-rendered HTML
532
809
  fs::write(html_path, pre_rendered)
@@ -572,31 +849,46 @@ impl Compiler {
572
849
  // Find all HTML files in compiled output (excluding components directory)
573
850
  let html_files = self.find_all_html_files(&self.config.output)?;
574
851
 
575
- for html_path in html_files {
576
- let relative_path = html_path.strip_prefix(&self.config.output)
577
- .unwrap()
578
- .to_str()
579
- .unwrap();
580
-
581
- // Read compiled HTML
582
- let html = fs::read_to_string(&html_path)
583
- .map_err(|e| CompileError::ReadError {
584
- path: html_path.display().to_string(),
585
- source: e,
586
- })?;
852
+ // Process manifests in parallel
853
+ let output_dir = self.config.output.clone();
854
+ let source_root = self.config.source.clone();
855
+ let verbose = self.verbose;
856
+ let iterations_as_is = self.config.iterations_as_is;
857
+ let components_as_is = self.config.components_as_is;
858
+
859
+ let results: Vec<_> = html_files
860
+ .par_iter()
861
+ .map(|html_path| {
862
+ let relative_path = html_path.strip_prefix(&output_dir)
863
+ .unwrap()
864
+ .to_str()
865
+ .unwrap();
866
+
867
+ // Read compiled HTML
868
+ let html = match fs::read_to_string(html_path) {
869
+ Ok(h) => h,
870
+ Err(_) => return (false, Some(relative_path.to_string())),
871
+ };
587
872
 
588
- // Try to generate manifest for this file (skip on error)
589
- match Self::generate_file_manifest(&html, &html_path, &self.config.output, relative_path, self.verbose) {
590
- Ok(()) => {
591
- pages_processed += 1;
592
- }
593
- Err(e) => {
594
- pages_skipped += 1;
595
- if self.verbose {
596
- println!(" Skipped ({}): {}", e, relative_path);
873
+ // Try to generate manifest for this file (skip on error)
874
+ match Self::generate_file_manifest(&html, html_path, &output_dir, relative_path, verbose, iterations_as_is, components_as_is, &source_root) {
875
+ Ok(()) => (true, None),
876
+ Err(e) => {
877
+ if verbose {
878
+ eprintln!(" Skipped ({}): {}", e, relative_path);
879
+ }
880
+ (false, Some(relative_path.to_string()))
597
881
  }
598
- // Don't fail compilation, just skip this file's manifest
599
882
  }
883
+ })
884
+ .collect();
885
+
886
+ // Count results
887
+ for (success, _) in results {
888
+ if success {
889
+ pages_processed += 1;
890
+ } else {
891
+ pages_skipped += 1;
600
892
  }
601
893
  }
602
894
 
@@ -615,6 +907,8 @@ impl Compiler {
615
907
  dir: &Path,
616
908
  parser: &HtmlParser,
617
909
  relative_path: &str,
910
+ canonical_output: &Path,
911
+ canonical_source: &Path,
618
912
  stats: &mut CompileStats,
619
913
  ) -> Result<(), CompileError> {
620
914
  let entries = fs::read_dir(dir).map_err(|e| CompileError::ReadError {
@@ -627,8 +921,14 @@ impl Compiler {
627
921
  let file_name = path.file_name().unwrap().to_str().unwrap();
628
922
 
629
923
  if path.is_dir() {
924
+ // Skip output directory (dynamically check, not hardcoded "compiled")
925
+ let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone());
926
+ if canonical_path == *canonical_output || canonical_path.starts_with(canonical_output) {
927
+ continue;
928
+ }
929
+
630
930
  // Skip special directories
631
- if SKIP_DIRECTORIES.contains(&file_name) {
931
+ if should_skip_path(&path, file_name) {
632
932
  continue;
633
933
  }
634
934
 
@@ -644,7 +944,7 @@ impl Compiler {
644
944
  format!("{}/{}", relative_path, file_name)
645
945
  };
646
946
 
647
- self.process_directory_html_only(&path, parser, &new_relative, stats)?;
947
+ self.process_directory_html_only(&path, parser, &new_relative, canonical_output, canonical_source, stats)?;
648
948
  } else if path.extension().and_then(|e| e.to_str()) == Some("html") {
649
949
  let (internal, external, component_srcs) = self.compile_html_file(&path, parser, relative_path)?;
650
950
  stats.files_compiled += 1;
@@ -665,7 +965,7 @@ impl Compiler {
665
965
  };
666
966
 
667
967
  if let Some(ref mut logger) = self.logger {
668
- logger.log(&path, FileOperation::Compiled, &self.config.source);
968
+ logger.log(&path, FileOperation::Compiled, canonical_source);
669
969
 
670
970
  // Log each component occurrence (for counting)
671
971
  if let Some(all_srcs) = all_srcs {
@@ -692,6 +992,8 @@ impl Compiler {
692
992
  &mut self,
693
993
  dir: &Path,
694
994
  relative_path: &str,
995
+ canonical_output: &Path,
996
+ canonical_source: &Path,
695
997
  stats: &mut CompileStats,
696
998
  ) -> Result<(), CompileError> {
697
999
  let entries = fs::read_dir(dir).map_err(|e| CompileError::ReadError {
@@ -704,8 +1006,14 @@ impl Compiler {
704
1006
  let file_name = path.file_name().unwrap().to_str().unwrap();
705
1007
 
706
1008
  if path.is_dir() {
1009
+ // Skip output directory (dynamically check, not hardcoded "compiled")
1010
+ let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone());
1011
+ if canonical_path == *canonical_output || canonical_path.starts_with(canonical_output) {
1012
+ continue;
1013
+ }
1014
+
707
1015
  // Skip special directories
708
- if SKIP_DIRECTORIES.contains(&file_name) {
1016
+ if should_skip_path(&path, file_name) {
709
1017
  continue;
710
1018
  }
711
1019
 
@@ -723,7 +1031,7 @@ impl Compiler {
723
1031
  } else {
724
1032
  format!("{}/{}", relative_path, file_name)
725
1033
  };
726
- self.copy_directory(&path, &new_relative, stats)?;
1034
+ self.copy_directory(&path, &new_relative, canonical_source, stats)?;
727
1035
  }
728
1036
  // Skip further processing (don't recurse into components)
729
1037
  continue;
@@ -736,14 +1044,19 @@ impl Compiler {
736
1044
  format!("{}/{}", relative_path, file_name)
737
1045
  };
738
1046
 
739
- self.process_directory_assets_only(&path, &new_relative, stats)?;
1047
+ self.process_directory_assets_only(&path, &new_relative, canonical_output, canonical_source, stats)?;
740
1048
  } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
1049
+ // Skip files matching skip patterns
1050
+ if should_skip_path(&path, file_name) {
1051
+ continue;
1052
+ }
1053
+
741
1054
  // Only copy non-HTML assets
742
1055
  if ext != "html" && MIRROR_EXTENSIONS.contains(&ext) {
743
1056
  self.copy_file(&path, relative_path)?;
744
1057
  stats.files_copied += 1;
745
1058
  if let Some(ref mut logger) = self.logger {
746
- logger.log(&path, FileOperation::Copied, &self.config.source);
1059
+ logger.log(&path, FileOperation::Copied, canonical_source);
747
1060
  }
748
1061
  }
749
1062
  }
@@ -775,14 +1088,14 @@ impl Compiler {
775
1088
  (0, 0, Vec::new())
776
1089
  };
777
1090
 
778
- // Compile: transform custom tags to <component>, inline if needed, accessibility transform
1091
+ // Compile: transform custom tags to <component>, inline if needed, transform custom elements
779
1092
  let processed = parser.process_html_with_cache(
780
1093
  &content,
781
- self.config.accessibility,
782
- &self.config.exclude_tags,
1094
+ self.config.elements_as_is,
1095
+ &self.config.reserved_elements,
783
1096
  self.config.components_as_is,
784
1097
  &self.config.components,
785
- &self.external_component_cache,
1098
+ &self.component_cache,
786
1099
  );
787
1100
 
788
1101
  // Minify if requested
@@ -844,7 +1157,7 @@ impl Compiler {
844
1157
 
845
1158
  // Read component content
846
1159
  let content = if component_src.starts_with("http://") || component_src.starts_with("https://") {
847
- match self.fetch_external_component(component_src) {
1160
+ match self.fetch_external_component_raw(component_src) {
848
1161
  Ok(c) => c,
849
1162
  Err(_) => return,
850
1163
  }
@@ -883,10 +1196,42 @@ impl Compiler {
883
1196
  Ok(())
884
1197
  }
885
1198
 
886
- fn validate_all_components(&mut self, parser: &HtmlParser) -> Result<(), CompileError> {
887
- let mut errors = Vec::new();
1199
+ /// Fetch components only for specific files (used in incremental compilation)
1200
+ fn fetch_components_for_files(&mut self, files: &[PathBuf], parser: &HtmlParser) -> Result<(), CompileError> {
1201
+ for html_file in files {
1202
+ let content = match fs::read_to_string(html_file) {
1203
+ Ok(c) => c,
1204
+ Err(e) => {
1205
+ if self.verbose {
1206
+ eprintln!(" Warning: Failed to read {}: {}", html_file.display(), e);
1207
+ }
1208
+ continue;
1209
+ }
1210
+ };
888
1211
 
889
- // Find all HTML files in source directory
1212
+ // Transform custom tags to <component> tags
1213
+ let transformed = parser.process_html(
1214
+ &content,
1215
+ false, // elements_as_is
1216
+ &[], // reserved_elements
1217
+ true, // components_as_is (don't inline, just transform)
1218
+ &self.config.components,
1219
+ );
1220
+
1221
+ // Extract and fetch components recursively
1222
+ let components = self.extract_component_srcs(&transformed);
1223
+ for component_src in components {
1224
+ self.fetch_component_recursive(&component_src, parser);
1225
+ }
1226
+ }
1227
+
1228
+ Ok(())
1229
+ }
1230
+
1231
+ /// Fetch and cache all components for inlining (separate from validation)
1232
+ /// This recursively fetches all components (external and internal) and populates component_cache
1233
+ fn fetch_all_components(&mut self, parser: &HtmlParser) -> Result<(), CompileError> {
1234
+ // Find all HTML files in source directory (need to scan pages to find external component references)
890
1235
  let html_files = self.find_all_html_files(&self.config.source)?;
891
1236
 
892
1237
  // Scan each HTML file for components
@@ -894,135 +1239,159 @@ impl Compiler {
894
1239
  let content = match fs::read_to_string(&html_file) {
895
1240
  Ok(c) => c,
896
1241
  Err(e) => {
897
- errors.push(ComponentError {
898
- component_src: html_file.display().to_string(),
899
- referenced_in: "source".to_string(),
900
- error_message: format!("Failed to read file: {}", e),
901
- });
1242
+ if self.verbose {
1243
+ eprintln!(" Warning: Failed to read {}: {}", html_file.display(), e);
1244
+ }
902
1245
  continue;
903
1246
  }
904
1247
  };
905
1248
 
906
- // Transform custom tags to <component> tags (e.g., <headline> → <component src="/components/Headline.html">)
907
- // Use process_html with elements_as_is=true to transform without inlining
1249
+ // Transform custom tags to <component> tags
908
1250
  let transformed = parser.process_html(
909
1251
  &content,
910
- false, // accessibility
911
- &[], // exclude_tags
912
- true, // elements_as_is (don't inline, just transform)
1252
+ false, // elements_as_is
1253
+ &[], // reserved_elements
1254
+ true, // components_as_is (don't inline, just transform)
913
1255
  &self.config.components,
914
1256
  );
915
1257
 
916
- // Extract components from transformed content
1258
+ // Extract and fetch components recursively
917
1259
  let components = self.extract_component_srcs(&transformed);
918
1260
  for component_src in components {
919
- self.validate_component_recursive(
920
- &component_src,
921
- &html_file.display().to_string(),
922
- &mut errors,
923
- parser,
924
- );
1261
+ self.fetch_component_recursive(&component_src, parser);
925
1262
  }
926
1263
  }
927
1264
 
928
- // If any errors, return them all
929
- if !errors.is_empty() {
930
- return Err(CompileError::ComponentValidationFailed(errors));
931
- }
932
-
933
1265
  Ok(())
934
1266
  }
935
1267
 
936
- fn validate_component_recursive(
937
- &mut self,
938
- component_src: &str,
939
- referenced_in: &str,
940
- errors: &mut Vec<ComponentError>,
941
- parser: &HtmlParser,
942
- ) {
943
- // Skip if we've already scanned this component
944
- if self.scanned_components.contains(component_src) {
945
- return;
1268
+ /// Recursively fetch a component and its nested components (for inlining)
1269
+ /// Returns the fully inlined content (all nested components resolved)
1270
+ fn fetch_component_recursive(&mut self, component_src: &str, parser: &HtmlParser) -> Option<String> {
1271
+ // Normalize path: ensure it starts with / (unless it's a URL)
1272
+ let normalized_src = if component_src.starts_with("http://") || component_src.starts_with("https://") {
1273
+ component_src.to_string()
1274
+ } else {
1275
+ let without_prefix = component_src.trim_start_matches("./");
1276
+ if without_prefix.starts_with('/') {
1277
+ without_prefix.to_string()
1278
+ } else {
1279
+ format!("/{}", without_prefix)
1280
+ }
1281
+ };
1282
+
1283
+ // Return cached if already fetched and fully resolved
1284
+ if let Some(cached) = self.component_cache.get(&normalized_src) {
1285
+ return Some(cached.clone());
946
1286
  }
947
- self.scanned_components.insert(component_src.to_string());
948
-
949
- // Check if it's external (http:// or https://)
950
- if component_src.starts_with("http://") || component_src.starts_with("https://") {
951
- // Fetch external component
952
- match self.fetch_external_component(component_src) {
953
- Ok(content) => {
954
- // Transform custom tags in fetched content
955
- let transformed = parser.process_html(
956
- &content,
957
- false,
958
- &[],
959
- true,
960
- &self.config.components,
961
- );
962
-
963
- // Recursively scan for nested components
964
- let nested = self.extract_component_srcs(&transformed);
965
- for nested_src in nested {
966
- self.validate_component_recursive(&nested_src, component_src, errors, parser);
967
- }
968
- }
1287
+
1288
+ // Fetch external or read internal component (raw content)
1289
+ let content = if normalized_src.starts_with("http://") || normalized_src.starts_with("https://") {
1290
+ match self.fetch_external_component_raw(&normalized_src) {
1291
+ Ok(c) => c,
969
1292
  Err(e) => {
970
- errors.push(ComponentError {
971
- component_src: component_src.to_string(),
972
- referenced_in: referenced_in.to_string(),
973
- error_message: e,
974
- });
1293
+ if self.verbose {
1294
+ eprintln!(" Warning: Failed to fetch {}: {}", normalized_src, e);
1295
+ }
1296
+ return None;
975
1297
  }
976
1298
  }
977
1299
  } else {
978
- // Internal component - resolve path
979
- let component_path = self.resolve_component_path(component_src);
980
- match component_path {
981
- Ok(path) => {
982
- // Read component file
983
- match fs::read_to_string(&path) {
984
- Ok(content) => {
985
- // Transform custom tags in component content
986
- let transformed = parser.process_html(
987
- &content,
988
- false,
989
- &[],
990
- true,
991
- &self.config.components,
992
- );
993
-
994
- // Recursively scan for nested components
995
- let nested = self.extract_component_srcs(&transformed);
996
- for nested_src in nested {
997
- self.validate_component_recursive(&nested_src, component_src, errors, parser);
998
- }
999
- }
1000
- Err(e) => {
1001
- errors.push(ComponentError {
1002
- component_src: component_src.to_string(),
1003
- referenced_in: referenced_in.to_string(),
1004
- error_message: format!("Failed to read component file: {}", e),
1005
- });
1300
+ match self.resolve_component_path(&normalized_src) {
1301
+ Ok(path) => match fs::read_to_string(&path) {
1302
+ Ok(c) => c,
1303
+ Err(e) => {
1304
+ if self.verbose {
1305
+ eprintln!(" Warning: Failed to read component {}: {}", normalized_src, e);
1006
1306
  }
1307
+ return None;
1007
1308
  }
1008
- }
1309
+ },
1009
1310
  Err(e) => {
1010
- errors.push(ComponentError {
1011
- component_src: component_src.to_string(),
1012
- referenced_in: referenced_in.to_string(),
1013
- error_message: e,
1014
- });
1311
+ if self.verbose {
1312
+ eprintln!(" Warning: {}", e);
1313
+ }
1314
+ return None;
1015
1315
  }
1016
1316
  }
1317
+ };
1318
+
1319
+ // Transform custom tags in component (but keep component tags as-is for now)
1320
+ let mut transformed = parser.process_html(
1321
+ &content,
1322
+ self.config.elements_as_is, // respect global config
1323
+ &self.config.reserved_elements.clone(), // respect global config
1324
+ true, // components_as_is = true (keep <component> tags for now so we can recursively resolve them)
1325
+ &self.config.components,
1326
+ );
1327
+
1328
+ // Recursively fetch and inline all nested components
1329
+ let nested = self.extract_component_srcs(&transformed);
1330
+ for nested_src in nested {
1331
+ if let Some(nested_content) = self.fetch_component_recursive(&nested_src, parser) {
1332
+ // Inline this nested component into the current component
1333
+ transformed = parser.inline_single_component(&transformed, &nested_src, &nested_content);
1334
+ }
1017
1335
  }
1336
+
1337
+ // Cache the fully resolved content
1338
+ self.component_cache.insert(normalized_src.clone(), transformed.clone());
1339
+
1340
+ Some(transformed)
1018
1341
  }
1019
1342
 
1020
- fn fetch_external_component(&mut self, url: &str) -> Result<String, String> {
1021
- // Check cache first
1022
- if let Some(cached) = self.external_component_cache.get(url) {
1023
- return Ok(cached.clone());
1343
+ /// Validate that component filenames don't match reserved elements
1344
+ fn validate_component_names(&self) -> Result<(), CompileError> {
1345
+ let components_dir = self.config.components_path();
1346
+
1347
+ if !components_dir.exists() {
1348
+ return Ok(()); // No components directory
1024
1349
  }
1025
1350
 
1351
+ let component_files = self.find_all_html_files(&components_dir)?;
1352
+
1353
+ for component_file in component_files {
1354
+ let file_name = component_file
1355
+ .file_stem()
1356
+ .and_then(|s| s.to_str())
1357
+ .unwrap_or("");
1358
+
1359
+ // Check if filename (case-sensitive) matches any reserved element
1360
+ if self.config.reserved_elements.contains(&file_name.to_string()) {
1361
+ let relative_path = component_file
1362
+ .strip_prefix(&self.config.source)
1363
+ .unwrap_or(&component_file);
1364
+
1365
+ return Err(CompileError::ReservedComponentName {
1366
+ component_name: file_name.to_string(),
1367
+ file_path: relative_path.display().to_string(),
1368
+ });
1369
+ }
1370
+ }
1371
+
1372
+ Ok(())
1373
+ }
1374
+
1375
+ /// Validate HTML syntax for all files (controlled by validate flag)
1376
+ fn validate_html_syntax(&self) -> Result<(), CompileError> {
1377
+ // Find all HTML files in source directory
1378
+ let html_files = self.find_all_html_files(&self.config.source)?;
1379
+
1380
+ for html_file in html_files {
1381
+ let content = fs::read_to_string(&html_file).map_err(|e| CompileError::ReadError {
1382
+ path: html_file.display().to_string(),
1383
+ source: e,
1384
+ })?;
1385
+
1386
+ // Validate HTML syntax
1387
+ self.validate_html(&content, &html_file)?;
1388
+ }
1389
+
1390
+ Ok(())
1391
+ }
1392
+
1393
+ /// Fetch external component without caching (returns raw content)
1394
+ fn fetch_external_component_raw(&self, url: &str) -> Result<String, String> {
1026
1395
  // Fetch from URL
1027
1396
  match reqwest::blocking::get(url) {
1028
1397
  Ok(response) => {
@@ -1030,11 +1399,7 @@ impl Compiler {
1030
1399
  return Err(format!("HTTP {} - {}", response.status().as_u16(), response.status().canonical_reason().unwrap_or("Unknown")));
1031
1400
  }
1032
1401
  match response.text() {
1033
- Ok(content) => {
1034
- // Cache the result
1035
- self.external_component_cache.insert(url.to_string(), content.clone());
1036
- Ok(content)
1037
- }
1402
+ Ok(content) => Ok(content),
1038
1403
  Err(e) => Err(format!("Failed to read response body: {}", e)),
1039
1404
  }
1040
1405
  }
@@ -1057,7 +1422,9 @@ impl Compiler {
1057
1422
 
1058
1423
  fn extract_component_srcs(&self, content: &str) -> Vec<String> {
1059
1424
  let mut srcs = Vec::new();
1060
- let re = Regex::new(r#"<component[^>]+src\s*=\s*["']([^"']+)["']"#).unwrap();
1425
+ // Match both <component src="..."> and <div class="component" src="...">
1426
+ // After elements_as_is=false transform, <component> becomes <div class="component">
1427
+ let re = Regex::new(r#"(?:<component|<div\s+class="component")[^>]+src\s*=\s*["']([^"']+)["']"#).unwrap();
1061
1428
  for cap in re.captures_iter(content) {
1062
1429
  if let Some(src) = cap.get(1) {
1063
1430
  srcs.push(src.as_str().to_string());
@@ -1068,11 +1435,15 @@ impl Compiler {
1068
1435
 
1069
1436
  fn find_all_html_files(&self, dir: &Path) -> Result<Vec<PathBuf>, CompileError> {
1070
1437
  let mut html_files = Vec::new();
1071
- self.find_html_files_recursive(dir, &mut html_files)?;
1438
+ // Get output directory basename to skip nested directories with same name
1439
+ let output_dir_name = self.config.output.file_name()
1440
+ .and_then(|n| n.to_str())
1441
+ .unwrap_or("");
1442
+ self.find_html_files_recursive(dir, &mut html_files, output_dir_name)?;
1072
1443
  Ok(html_files)
1073
1444
  }
1074
1445
 
1075
- fn find_html_files_recursive(&self, dir: &Path, files: &mut Vec<PathBuf>) -> Result<(), CompileError> {
1446
+ fn find_html_files_recursive(&self, dir: &Path, files: &mut Vec<PathBuf>, output_dir_name: &str) -> Result<(), CompileError> {
1076
1447
  if !dir.is_dir() {
1077
1448
  return Ok(());
1078
1449
  }
@@ -1092,8 +1463,8 @@ impl Compiler {
1092
1463
  let file_name = entry.file_name();
1093
1464
  let file_name_str = file_name.to_string_lossy();
1094
1465
 
1095
- // Skip hidden files and specific directories
1096
- if file_name_str.starts_with('.') || SKIP_DIRECTORIES.contains(&file_name_str.as_ref()) {
1466
+ // Skip hidden files and specific directories/patterns
1467
+ if file_name_str.starts_with('.') || should_skip_path(&path, &file_name_str) {
1097
1468
  continue;
1098
1469
  }
1099
1470
 
@@ -1102,8 +1473,13 @@ impl Compiler {
1102
1473
  continue;
1103
1474
  }
1104
1475
 
1476
+ // Skip nested directories with same name as output dir (from previous bad compilations)
1477
+ if !output_dir_name.is_empty() && file_name_str == output_dir_name {
1478
+ continue;
1479
+ }
1480
+
1105
1481
  if path.is_dir() {
1106
- self.find_html_files_recursive(&path, files)?;
1482
+ self.find_html_files_recursive(&path, files, output_dir_name)?;
1107
1483
  } else if path.extension().and_then(|e| e.to_str()) == Some("html") {
1108
1484
  files.push(path);
1109
1485
  }
@@ -1151,6 +1527,7 @@ impl Compiler {
1151
1527
  &mut self,
1152
1528
  dir: &Path,
1153
1529
  relative_path: &str,
1530
+ canonical_source: &Path,
1154
1531
  stats: &mut CompileStats,
1155
1532
  ) -> Result<(), CompileError> {
1156
1533
  let entries = fs::read_dir(dir).map_err(|e| CompileError::ReadError {
@@ -1162,14 +1539,19 @@ impl Compiler {
1162
1539
  let path = entry.path();
1163
1540
  let file_name = path.file_name().unwrap().to_str().unwrap();
1164
1541
 
1542
+ // Skip files/directories matching skip patterns
1543
+ if should_skip_path(&path, file_name) {
1544
+ continue;
1545
+ }
1546
+
1165
1547
  if path.is_dir() {
1166
1548
  let new_relative = format!("{}/{}", relative_path, file_name);
1167
- self.copy_directory(&path, &new_relative, stats)?;
1549
+ self.copy_directory(&path, &new_relative, canonical_source, stats)?;
1168
1550
  } else {
1169
1551
  self.copy_file(&path, relative_path)?;
1170
1552
  stats.files_copied += 1;
1171
1553
  if let Some(ref mut logger) = self.logger {
1172
- logger.log(&path, FileOperation::Copied, &self.config.source);
1554
+ logger.log(&path, FileOperation::Copied, canonical_source);
1173
1555
  }
1174
1556
  }
1175
1557
  }