@ape-egg/vibe 1.3.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,18 @@ impl Compiler {
423
502
  }
424
503
  }
425
504
 
505
+ // Canonicalize output path once for reliable comparison
506
+ let canonical_output = self.config.output.canonicalize()
507
+ .unwrap_or_else(|_| self.config.output.clone());
426
508
 
427
509
  // Track compile and copy times separately
428
510
  let compile_start = Instant::now();
429
511
 
430
512
  // Process all HTML files in source (excluding components directory)
431
- self.process_directory_html_only(&self.config.source.clone(), &parser, "", &mut stats)?;
513
+ self.process_directory_html_only(&self.config.source.clone(), &parser, "", &canonical_output, &mut stats)?;
432
514
 
433
515
  // Process source directory for assets (CSS, JS, images, etc.)
434
- self.process_directory_assets_only(&self.config.source.clone(), "", &mut stats)?;
516
+ self.process_directory_assets_only(&self.config.source.clone(), "", &canonical_output, &mut stats)?;
435
517
 
436
518
  let process_time = compile_start.elapsed();
437
519
 
@@ -505,6 +587,190 @@ impl Compiler {
505
587
  Ok(stats)
506
588
  }
507
589
 
590
+ /// Compile specific HTML files (incremental compilation for watch mode)
591
+ pub fn compile_specific_html_files(
592
+ &mut self,
593
+ files: &[PathBuf],
594
+ parser: &HtmlParser,
595
+ ) -> Result<CompileStats, CompileError> {
596
+ let mut stats = CompileStats {
597
+ files_compiled: 0,
598
+ files_copied: 0,
599
+ internal_components_unique: 0,
600
+ external_components_unique: 0,
601
+ internal_components_total: 0,
602
+ external_components_total: 0,
603
+ compile_time_ms: 0.0,
604
+ copy_time_ms: 0.0,
605
+ components_time_ms: 0.0,
606
+ validation_time_ms: None,
607
+ node_modules_time_ms: None,
608
+ package_manager: None,
609
+ node_modules_copied_as_is: false,
610
+ components_as_is: self.config.components_as_is,
611
+ };
612
+
613
+ let start = Instant::now();
614
+
615
+ // Pre-fetch components needed by these specific files (only if not components_as_is)
616
+ if !self.config.components_as_is {
617
+ let fetch_start = Instant::now();
618
+ self.fetch_components_for_files(files, parser)?;
619
+ stats.components_time_ms = fetch_start.elapsed().as_secs_f64() * 1000.0;
620
+ }
621
+
622
+ for file_path in files {
623
+ // Calculate relative path
624
+ let relative_path = file_path
625
+ .strip_prefix(&self.config.source)
626
+ .map(|p| p.parent().unwrap_or(Path::new("")))
627
+ .unwrap_or(Path::new(""))
628
+ .to_str()
629
+ .unwrap_or("");
630
+
631
+ let (internal, external, component_srcs) = self.compile_html_file(file_path, parser, relative_path)?;
632
+ stats.files_compiled += 1;
633
+ stats.internal_components_total += internal;
634
+ stats.external_components_total += external;
635
+
636
+ // Track unique components
637
+ for src in &component_srcs {
638
+ self.unique_components.insert(src.clone());
639
+ }
640
+
641
+ if let Some(ref mut logger) = self.logger {
642
+ logger.log(file_path, FileOperation::Compiled, &self.config.source);
643
+ for src in component_srcs {
644
+ logger.log_component_occurrence(src);
645
+ }
646
+ }
647
+ }
648
+
649
+ // Calculate unique components
650
+ let internal_unique = self.unique_components.iter()
651
+ .filter(|src| !src.starts_with("http://") && !src.starts_with("https://"))
652
+ .count();
653
+ let external_unique = self.unique_components.iter()
654
+ .filter(|src| src.starts_with("http://") || src.starts_with("https://"))
655
+ .count();
656
+
657
+ stats.internal_components_unique = internal_unique;
658
+ stats.external_components_unique = external_unique;
659
+
660
+ // Calculate compile time (excluding component fetch time which is already tracked)
661
+ let elapsed = start.elapsed().as_secs_f64() * 1000.0;
662
+ stats.compile_time_ms = elapsed - stats.components_time_ms;
663
+
664
+ Ok(stats)
665
+ }
666
+
667
+ /// Copy specific asset files (incremental compilation for watch mode)
668
+ pub fn copy_specific_asset_files(
669
+ &mut self,
670
+ files: &[PathBuf],
671
+ ) -> Result<CompileStats, CompileError> {
672
+ let mut stats = CompileStats {
673
+ files_compiled: 0,
674
+ files_copied: 0,
675
+ internal_components_unique: 0,
676
+ external_components_unique: 0,
677
+ internal_components_total: 0,
678
+ external_components_total: 0,
679
+ compile_time_ms: 0.0,
680
+ copy_time_ms: 0.0,
681
+ components_time_ms: 0.0,
682
+ validation_time_ms: None,
683
+ node_modules_time_ms: None,
684
+ package_manager: None,
685
+ node_modules_copied_as_is: false,
686
+ components_as_is: self.config.components_as_is,
687
+ };
688
+
689
+ let start = Instant::now();
690
+
691
+ for file_path in files {
692
+ // Calculate relative path for preserving directory structure
693
+ let relative_path = file_path
694
+ .strip_prefix(&self.config.source)
695
+ .map(|p| p.parent().unwrap_or(Path::new("")))
696
+ .unwrap_or(Path::new(""))
697
+ .to_str()
698
+ .unwrap_or("");
699
+
700
+ self.copy_file(file_path, relative_path)?;
701
+ stats.files_copied += 1;
702
+
703
+ if let Some(ref mut logger) = self.logger {
704
+ logger.log(file_path, FileOperation::Copied, &self.config.source);
705
+ }
706
+ }
707
+
708
+ let elapsed = start.elapsed().as_secs_f64() * 1000.0;
709
+ stats.copy_time_ms = elapsed;
710
+
711
+ Ok(stats)
712
+ }
713
+
714
+ /// Generate manifests for specific HTML files (incremental compilation for watch mode)
715
+ pub fn generate_manifests_for_files(&self, files: &[PathBuf]) -> Result<ManifestStats, CompileError> {
716
+ let start = Instant::now();
717
+ let mut pages_processed = 0;
718
+ let mut pages_skipped = 0;
719
+
720
+ for file_path in files {
721
+ // Convert source path to output path
722
+ let relative_path = file_path
723
+ .strip_prefix(&self.config.source)
724
+ .unwrap_or(file_path)
725
+ .to_str()
726
+ .unwrap();
727
+
728
+ let output_path = self.config.output.join(relative_path);
729
+
730
+ if !output_path.exists() {
731
+ pages_skipped += 1;
732
+ continue;
733
+ }
734
+
735
+ // Read compiled HTML from output
736
+ let html = fs::read_to_string(&output_path)
737
+ .map_err(|e| CompileError::ReadError {
738
+ path: output_path.display().to_string(),
739
+ source: e,
740
+ })?;
741
+
742
+ // Try to generate manifest for this file (skip on error)
743
+ match Self::generate_file_manifest(
744
+ &html,
745
+ &output_path,
746
+ &self.config.output,
747
+ relative_path,
748
+ self.verbose,
749
+ self.config.iterations_as_is,
750
+ self.config.components_as_is,
751
+ &self.config.source,
752
+ ) {
753
+ Ok(()) => {
754
+ pages_processed += 1;
755
+ }
756
+ Err(e) => {
757
+ pages_skipped += 1;
758
+ if self.verbose {
759
+ println!(" Skipped ({}): {}", e, relative_path);
760
+ }
761
+ }
762
+ }
763
+ }
764
+
765
+ let total_time_ms = start.elapsed().as_millis() as f64;
766
+
767
+ Ok(ManifestStats {
768
+ pages_processed,
769
+ pages_skipped,
770
+ total_time_ms,
771
+ })
772
+ }
773
+
508
774
  /// Generate manifest for a single file
509
775
  fn generate_file_manifest(
510
776
  html: &str,
@@ -512,21 +778,30 @@ impl Compiler {
512
778
  output_dir: &Path,
513
779
  relative_path: &str,
514
780
  _verbose: bool,
781
+ iterations_as_is: bool,
782
+ components_as_is: bool,
783
+ source_root: &Path,
515
784
  ) -> Result<(), String> {
516
- use crate::compiler::state_extractor::StateExtractor;
517
785
  use crate::compiler::manifest_builder::ManifestBuilder;
518
786
  use crate::compiler::value_stamper::ValueStamper;
787
+ use crate::compiler::component_tagger::ComponentTagger;
519
788
 
520
- // Extract state
521
- let state = StateExtractor::extract_from_html(html)?;
789
+ // Tag components with deterministic IDs and structure state
790
+ let tagged = ComponentTagger::tag_components(html, &source_root.to_path_buf())?;
791
+ let html = &tagged.html; // Use modified HTML with data-vibe-component-id attributes
792
+ let state = tagged.state;
522
793
 
523
- // Build manifest
794
+ // Build manifest from ORIGINAL HTML (before stamping, so templates have @[...] markers)
524
795
  let manifest_builder = ManifestBuilder::new();
525
- let manifest = manifest_builder.build_from_html(html, &state)?;
796
+ let manifest = manifest_builder.build_from_html(html, &state, iterations_as_is)?;
526
797
 
527
- // Stamp values (pre-render)
528
- let stamper = ValueStamper::new(&state);
529
- let pre_rendered = stamper.stamp_html(html.to_string())?;
798
+ // Stamp values (pre-render) AFTER building manifest
799
+ // When components_as_is is true, skip stamping inside component elements (runtime will handle them)
800
+ let stamper = ValueStamper::new(&state, components_as_is)?;
801
+ let mut pre_rendered = stamper.stamp_html(html.to_string())?;
802
+
803
+ // Remove FOUC prevention since HTML is pre-rendered
804
+ pre_rendered = remove_fouc_prevention(pre_rendered);
530
805
 
531
806
  // Write pre-rendered HTML
532
807
  fs::write(html_path, pre_rendered)
@@ -572,31 +847,46 @@ impl Compiler {
572
847
  // Find all HTML files in compiled output (excluding components directory)
573
848
  let html_files = self.find_all_html_files(&self.config.output)?;
574
849
 
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
- })?;
850
+ // Process manifests in parallel
851
+ let output_dir = self.config.output.clone();
852
+ let source_root = self.config.source.clone();
853
+ let verbose = self.verbose;
854
+ let iterations_as_is = self.config.iterations_as_is;
855
+ let components_as_is = self.config.components_as_is;
856
+
857
+ let results: Vec<_> = html_files
858
+ .par_iter()
859
+ .map(|html_path| {
860
+ let relative_path = html_path.strip_prefix(&output_dir)
861
+ .unwrap()
862
+ .to_str()
863
+ .unwrap();
864
+
865
+ // Read compiled HTML
866
+ let html = match fs::read_to_string(html_path) {
867
+ Ok(h) => h,
868
+ Err(_) => return (false, Some(relative_path.to_string())),
869
+ };
587
870
 
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);
871
+ // Try to generate manifest for this file (skip on error)
872
+ match Self::generate_file_manifest(&html, html_path, &output_dir, relative_path, verbose, iterations_as_is, components_as_is, &source_root) {
873
+ Ok(()) => (true, None),
874
+ Err(e) => {
875
+ if verbose {
876
+ eprintln!(" Skipped ({}): {}", e, relative_path);
877
+ }
878
+ (false, Some(relative_path.to_string()))
597
879
  }
598
- // Don't fail compilation, just skip this file's manifest
599
880
  }
881
+ })
882
+ .collect();
883
+
884
+ // Count results
885
+ for (success, _) in results {
886
+ if success {
887
+ pages_processed += 1;
888
+ } else {
889
+ pages_skipped += 1;
600
890
  }
601
891
  }
602
892
 
@@ -615,6 +905,7 @@ impl Compiler {
615
905
  dir: &Path,
616
906
  parser: &HtmlParser,
617
907
  relative_path: &str,
908
+ canonical_output: &Path,
618
909
  stats: &mut CompileStats,
619
910
  ) -> Result<(), CompileError> {
620
911
  let entries = fs::read_dir(dir).map_err(|e| CompileError::ReadError {
@@ -627,8 +918,14 @@ impl Compiler {
627
918
  let file_name = path.file_name().unwrap().to_str().unwrap();
628
919
 
629
920
  if path.is_dir() {
921
+ // Skip output directory (dynamically check, not hardcoded "compiled")
922
+ let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone());
923
+ if canonical_path == *canonical_output || canonical_path.starts_with(canonical_output) {
924
+ continue;
925
+ }
926
+
630
927
  // Skip special directories
631
- if SKIP_DIRECTORIES.contains(&file_name) {
928
+ if should_skip_path(&path, file_name) {
632
929
  continue;
633
930
  }
634
931
 
@@ -644,7 +941,7 @@ impl Compiler {
644
941
  format!("{}/{}", relative_path, file_name)
645
942
  };
646
943
 
647
- self.process_directory_html_only(&path, parser, &new_relative, stats)?;
944
+ self.process_directory_html_only(&path, parser, &new_relative, canonical_output, stats)?;
648
945
  } else if path.extension().and_then(|e| e.to_str()) == Some("html") {
649
946
  let (internal, external, component_srcs) = self.compile_html_file(&path, parser, relative_path)?;
650
947
  stats.files_compiled += 1;
@@ -692,6 +989,7 @@ impl Compiler {
692
989
  &mut self,
693
990
  dir: &Path,
694
991
  relative_path: &str,
992
+ canonical_output: &Path,
695
993
  stats: &mut CompileStats,
696
994
  ) -> Result<(), CompileError> {
697
995
  let entries = fs::read_dir(dir).map_err(|e| CompileError::ReadError {
@@ -704,8 +1002,14 @@ impl Compiler {
704
1002
  let file_name = path.file_name().unwrap().to_str().unwrap();
705
1003
 
706
1004
  if path.is_dir() {
1005
+ // Skip output directory (dynamically check, not hardcoded "compiled")
1006
+ let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone());
1007
+ if canonical_path == *canonical_output || canonical_path.starts_with(canonical_output) {
1008
+ continue;
1009
+ }
1010
+
707
1011
  // Skip special directories
708
- if SKIP_DIRECTORIES.contains(&file_name) {
1012
+ if should_skip_path(&path, file_name) {
709
1013
  continue;
710
1014
  }
711
1015
 
@@ -736,8 +1040,13 @@ impl Compiler {
736
1040
  format!("{}/{}", relative_path, file_name)
737
1041
  };
738
1042
 
739
- self.process_directory_assets_only(&path, &new_relative, stats)?;
1043
+ self.process_directory_assets_only(&path, &new_relative, canonical_output, stats)?;
740
1044
  } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
1045
+ // Skip files matching skip patterns
1046
+ if should_skip_path(&path, file_name) {
1047
+ continue;
1048
+ }
1049
+
741
1050
  // Only copy non-HTML assets
742
1051
  if ext != "html" && MIRROR_EXTENSIONS.contains(&ext) {
743
1052
  self.copy_file(&path, relative_path)?;
@@ -775,14 +1084,14 @@ impl Compiler {
775
1084
  (0, 0, Vec::new())
776
1085
  };
777
1086
 
778
- // Compile: transform custom tags to <component>, inline if needed, accessibility transform
1087
+ // Compile: transform custom tags to <component>, inline if needed, transform custom elements
779
1088
  let processed = parser.process_html_with_cache(
780
1089
  &content,
781
- self.config.accessibility,
782
- &self.config.exclude_tags,
1090
+ self.config.elements_as_is,
1091
+ &self.config.reserved_elements,
783
1092
  self.config.components_as_is,
784
1093
  &self.config.components,
785
- &self.external_component_cache,
1094
+ &self.component_cache,
786
1095
  );
787
1096
 
788
1097
  // Minify if requested
@@ -844,7 +1153,7 @@ impl Compiler {
844
1153
 
845
1154
  // Read component content
846
1155
  let content = if component_src.starts_with("http://") || component_src.starts_with("https://") {
847
- match self.fetch_external_component(component_src) {
1156
+ match self.fetch_external_component_raw(component_src) {
848
1157
  Ok(c) => c,
849
1158
  Err(_) => return,
850
1159
  }
@@ -883,10 +1192,42 @@ impl Compiler {
883
1192
  Ok(())
884
1193
  }
885
1194
 
886
- fn validate_all_components(&mut self, parser: &HtmlParser) -> Result<(), CompileError> {
887
- let mut errors = Vec::new();
1195
+ /// Fetch components only for specific files (used in incremental compilation)
1196
+ fn fetch_components_for_files(&mut self, files: &[PathBuf], parser: &HtmlParser) -> Result<(), CompileError> {
1197
+ for html_file in files {
1198
+ let content = match fs::read_to_string(html_file) {
1199
+ Ok(c) => c,
1200
+ Err(e) => {
1201
+ if self.verbose {
1202
+ eprintln!(" Warning: Failed to read {}: {}", html_file.display(), e);
1203
+ }
1204
+ continue;
1205
+ }
1206
+ };
888
1207
 
889
- // Find all HTML files in source directory
1208
+ // Transform custom tags to <component> tags
1209
+ let transformed = parser.process_html(
1210
+ &content,
1211
+ false, // elements_as_is
1212
+ &[], // reserved_elements
1213
+ true, // components_as_is (don't inline, just transform)
1214
+ &self.config.components,
1215
+ );
1216
+
1217
+ // Extract and fetch components recursively
1218
+ let components = self.extract_component_srcs(&transformed);
1219
+ for component_src in components {
1220
+ self.fetch_component_recursive(&component_src, parser);
1221
+ }
1222
+ }
1223
+
1224
+ Ok(())
1225
+ }
1226
+
1227
+ /// Fetch and cache all components for inlining (separate from validation)
1228
+ /// This recursively fetches all components (external and internal) and populates component_cache
1229
+ fn fetch_all_components(&mut self, parser: &HtmlParser) -> Result<(), CompileError> {
1230
+ // Find all HTML files in source directory (need to scan pages to find external component references)
890
1231
  let html_files = self.find_all_html_files(&self.config.source)?;
891
1232
 
892
1233
  // Scan each HTML file for components
@@ -894,135 +1235,159 @@ impl Compiler {
894
1235
  let content = match fs::read_to_string(&html_file) {
895
1236
  Ok(c) => c,
896
1237
  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
- });
1238
+ if self.verbose {
1239
+ eprintln!(" Warning: Failed to read {}: {}", html_file.display(), e);
1240
+ }
902
1241
  continue;
903
1242
  }
904
1243
  };
905
1244
 
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
1245
+ // Transform custom tags to <component> tags
908
1246
  let transformed = parser.process_html(
909
1247
  &content,
910
- false, // accessibility
911
- &[], // exclude_tags
912
- true, // elements_as_is (don't inline, just transform)
1248
+ false, // elements_as_is
1249
+ &[], // reserved_elements
1250
+ true, // components_as_is (don't inline, just transform)
913
1251
  &self.config.components,
914
1252
  );
915
1253
 
916
- // Extract components from transformed content
1254
+ // Extract and fetch components recursively
917
1255
  let components = self.extract_component_srcs(&transformed);
918
1256
  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
- );
1257
+ self.fetch_component_recursive(&component_src, parser);
925
1258
  }
926
1259
  }
927
1260
 
928
- // If any errors, return them all
929
- if !errors.is_empty() {
930
- return Err(CompileError::ComponentValidationFailed(errors));
931
- }
932
-
933
1261
  Ok(())
934
1262
  }
935
1263
 
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;
1264
+ /// Recursively fetch a component and its nested components (for inlining)
1265
+ /// Returns the fully inlined content (all nested components resolved)
1266
+ fn fetch_component_recursive(&mut self, component_src: &str, parser: &HtmlParser) -> Option<String> {
1267
+ // Normalize path: ensure it starts with / (unless it's a URL)
1268
+ let normalized_src = if component_src.starts_with("http://") || component_src.starts_with("https://") {
1269
+ component_src.to_string()
1270
+ } else {
1271
+ let without_prefix = component_src.trim_start_matches("./");
1272
+ if without_prefix.starts_with('/') {
1273
+ without_prefix.to_string()
1274
+ } else {
1275
+ format!("/{}", without_prefix)
1276
+ }
1277
+ };
1278
+
1279
+ // Return cached if already fetched and fully resolved
1280
+ if let Some(cached) = self.component_cache.get(&normalized_src) {
1281
+ return Some(cached.clone());
946
1282
  }
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
- }
1283
+
1284
+ // Fetch external or read internal component (raw content)
1285
+ let content = if normalized_src.starts_with("http://") || normalized_src.starts_with("https://") {
1286
+ match self.fetch_external_component_raw(&normalized_src) {
1287
+ Ok(c) => c,
969
1288
  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
- });
1289
+ if self.verbose {
1290
+ eprintln!(" Warning: Failed to fetch {}: {}", normalized_src, e);
1291
+ }
1292
+ return None;
975
1293
  }
976
1294
  }
977
1295
  } 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
- });
1296
+ match self.resolve_component_path(&normalized_src) {
1297
+ Ok(path) => match fs::read_to_string(&path) {
1298
+ Ok(c) => c,
1299
+ Err(e) => {
1300
+ if self.verbose {
1301
+ eprintln!(" Warning: Failed to read component {}: {}", normalized_src, e);
1006
1302
  }
1303
+ return None;
1007
1304
  }
1008
- }
1305
+ },
1009
1306
  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
- });
1307
+ if self.verbose {
1308
+ eprintln!(" Warning: {}", e);
1309
+ }
1310
+ return None;
1015
1311
  }
1016
1312
  }
1313
+ };
1314
+
1315
+ // Transform custom tags in component (but keep component tags as-is for now)
1316
+ let mut transformed = parser.process_html(
1317
+ &content,
1318
+ self.config.elements_as_is, // respect global config
1319
+ &self.config.reserved_elements.clone(), // respect global config
1320
+ true, // components_as_is = true (keep <component> tags for now so we can recursively resolve them)
1321
+ &self.config.components,
1322
+ );
1323
+
1324
+ // Recursively fetch and inline all nested components
1325
+ let nested = self.extract_component_srcs(&transformed);
1326
+ for nested_src in nested {
1327
+ if let Some(nested_content) = self.fetch_component_recursive(&nested_src, parser) {
1328
+ // Inline this nested component into the current component
1329
+ transformed = parser.inline_single_component(&transformed, &nested_src, &nested_content);
1330
+ }
1017
1331
  }
1332
+
1333
+ // Cache the fully resolved content
1334
+ self.component_cache.insert(normalized_src.clone(), transformed.clone());
1335
+
1336
+ Some(transformed)
1018
1337
  }
1019
1338
 
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());
1339
+ /// Validate that component filenames don't match reserved elements
1340
+ fn validate_component_names(&self) -> Result<(), CompileError> {
1341
+ let components_dir = self.config.components_path();
1342
+
1343
+ if !components_dir.exists() {
1344
+ return Ok(()); // No components directory
1024
1345
  }
1025
1346
 
1347
+ let component_files = self.find_all_html_files(&components_dir)?;
1348
+
1349
+ for component_file in component_files {
1350
+ let file_name = component_file
1351
+ .file_stem()
1352
+ .and_then(|s| s.to_str())
1353
+ .unwrap_or("");
1354
+
1355
+ // Check if filename (case-sensitive) matches any reserved element
1356
+ if self.config.reserved_elements.contains(&file_name.to_string()) {
1357
+ let relative_path = component_file
1358
+ .strip_prefix(&self.config.source)
1359
+ .unwrap_or(&component_file);
1360
+
1361
+ return Err(CompileError::ReservedComponentName {
1362
+ component_name: file_name.to_string(),
1363
+ file_path: relative_path.display().to_string(),
1364
+ });
1365
+ }
1366
+ }
1367
+
1368
+ Ok(())
1369
+ }
1370
+
1371
+ /// Validate HTML syntax for all files (controlled by validate flag)
1372
+ fn validate_html_syntax(&self) -> Result<(), CompileError> {
1373
+ // Find all HTML files in source directory
1374
+ let html_files = self.find_all_html_files(&self.config.source)?;
1375
+
1376
+ for html_file in html_files {
1377
+ let content = fs::read_to_string(&html_file).map_err(|e| CompileError::ReadError {
1378
+ path: html_file.display().to_string(),
1379
+ source: e,
1380
+ })?;
1381
+
1382
+ // Validate HTML syntax
1383
+ self.validate_html(&content, &html_file)?;
1384
+ }
1385
+
1386
+ Ok(())
1387
+ }
1388
+
1389
+ /// Fetch external component without caching (returns raw content)
1390
+ fn fetch_external_component_raw(&self, url: &str) -> Result<String, String> {
1026
1391
  // Fetch from URL
1027
1392
  match reqwest::blocking::get(url) {
1028
1393
  Ok(response) => {
@@ -1030,11 +1395,7 @@ impl Compiler {
1030
1395
  return Err(format!("HTTP {} - {}", response.status().as_u16(), response.status().canonical_reason().unwrap_or("Unknown")));
1031
1396
  }
1032
1397
  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
- }
1398
+ Ok(content) => Ok(content),
1038
1399
  Err(e) => Err(format!("Failed to read response body: {}", e)),
1039
1400
  }
1040
1401
  }
@@ -1057,7 +1418,9 @@ impl Compiler {
1057
1418
 
1058
1419
  fn extract_component_srcs(&self, content: &str) -> Vec<String> {
1059
1420
  let mut srcs = Vec::new();
1060
- let re = Regex::new(r#"<component[^>]+src\s*=\s*["']([^"']+)["']"#).unwrap();
1421
+ // Match both <component src="..."> and <div class="component" src="...">
1422
+ // After elements_as_is=false transform, <component> becomes <div class="component">
1423
+ let re = Regex::new(r#"(?:<component|<div\s+class="component")[^>]+src\s*=\s*["']([^"']+)["']"#).unwrap();
1061
1424
  for cap in re.captures_iter(content) {
1062
1425
  if let Some(src) = cap.get(1) {
1063
1426
  srcs.push(src.as_str().to_string());
@@ -1068,11 +1431,15 @@ impl Compiler {
1068
1431
 
1069
1432
  fn find_all_html_files(&self, dir: &Path) -> Result<Vec<PathBuf>, CompileError> {
1070
1433
  let mut html_files = Vec::new();
1071
- self.find_html_files_recursive(dir, &mut html_files)?;
1434
+ // Get output directory basename to skip nested directories with same name
1435
+ let output_dir_name = self.config.output.file_name()
1436
+ .and_then(|n| n.to_str())
1437
+ .unwrap_or("");
1438
+ self.find_html_files_recursive(dir, &mut html_files, output_dir_name)?;
1072
1439
  Ok(html_files)
1073
1440
  }
1074
1441
 
1075
- fn find_html_files_recursive(&self, dir: &Path, files: &mut Vec<PathBuf>) -> Result<(), CompileError> {
1442
+ fn find_html_files_recursive(&self, dir: &Path, files: &mut Vec<PathBuf>, output_dir_name: &str) -> Result<(), CompileError> {
1076
1443
  if !dir.is_dir() {
1077
1444
  return Ok(());
1078
1445
  }
@@ -1092,8 +1459,8 @@ impl Compiler {
1092
1459
  let file_name = entry.file_name();
1093
1460
  let file_name_str = file_name.to_string_lossy();
1094
1461
 
1095
- // Skip hidden files and specific directories
1096
- if file_name_str.starts_with('.') || SKIP_DIRECTORIES.contains(&file_name_str.as_ref()) {
1462
+ // Skip hidden files and specific directories/patterns
1463
+ if file_name_str.starts_with('.') || should_skip_path(&path, &file_name_str) {
1097
1464
  continue;
1098
1465
  }
1099
1466
 
@@ -1102,8 +1469,13 @@ impl Compiler {
1102
1469
  continue;
1103
1470
  }
1104
1471
 
1472
+ // Skip nested directories with same name as output dir (from previous bad compilations)
1473
+ if !output_dir_name.is_empty() && file_name_str == output_dir_name {
1474
+ continue;
1475
+ }
1476
+
1105
1477
  if path.is_dir() {
1106
- self.find_html_files_recursive(&path, files)?;
1478
+ self.find_html_files_recursive(&path, files, output_dir_name)?;
1107
1479
  } else if path.extension().and_then(|e| e.to_str()) == Some("html") {
1108
1480
  files.push(path);
1109
1481
  }
@@ -1162,6 +1534,11 @@ impl Compiler {
1162
1534
  let path = entry.path();
1163
1535
  let file_name = path.file_name().unwrap().to_str().unwrap();
1164
1536
 
1537
+ // Skip files/directories matching skip patterns
1538
+ if should_skip_path(&path, file_name) {
1539
+ continue;
1540
+ }
1541
+
1165
1542
  if path.is_dir() {
1166
1543
  let new_relative = format!("{}/{}", relative_path, file_name);
1167
1544
  self.copy_directory(&path, &new_relative, stats)?;