@ape-egg/vibe 1.0.5 → 1.1.2

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +228 -23
  3. package/compiler/bin/vibe-compile.js +109 -0
  4. package/compiler/native/.gitkeep +0 -0
  5. package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
  6. package/compiler/src/Cargo.lock +1885 -0
  7. package/compiler/src/Cargo.toml +29 -0
  8. package/compiler/src/compiler/compile.rs +1209 -0
  9. package/compiler/src/compiler/mod.rs +5 -0
  10. package/compiler/src/config.rs +184 -0
  11. package/compiler/src/main.rs +284 -0
  12. package/compiler/src/parser/element.rs +96 -0
  13. package/compiler/src/parser/html.rs +335 -0
  14. package/compiler/src/parser/mod.rs +8 -0
  15. package/index.js +2 -248
  16. package/package.json +26 -3
  17. package/{affected.js → runtime/affected.js} +64 -4
  18. package/runtime/cleanup.js +59 -0
  19. package/runtime/component.js +116 -0
  20. package/{conditionals.js → runtime/conditionals.js} +25 -11
  21. package/{constants.js → runtime/constants.js} +23 -3
  22. package/runtime/debug.js +91 -0
  23. package/{hydrate.js → runtime/hydrate.js} +57 -7
  24. package/runtime/index.js +614 -0
  25. package/{iterate.js → runtime/iterate.js} +53 -45
  26. package/{iteration-utils.js → runtime/iteration-utils.js} +11 -1
  27. package/{parse.js → runtime/parse.js} +37 -7
  28. package/runtime/state.js +52 -0
  29. package/ROADMAP.md +0 -289
  30. package/llms.txt +0 -279
  31. package/state.js +0 -26
  32. /package/{_vibe-compiled-iteration-batch.js → runtime/_vibe-compiled-iteration-batch.js} +0 -0
  33. /package/{link.js → runtime/manifest.js} +0 -0
  34. /package/{utils.js → runtime/utils.js} +0 -0
  35. /package/{vibe.css → runtime/vibe.css} +0 -0
@@ -0,0 +1,1209 @@
1
+ use std::fs;
2
+ use std::path::{Path, PathBuf};
3
+ use std::process::Command;
4
+ use std::time::Instant;
5
+ use std::collections::{BTreeMap, HashSet, HashMap};
6
+ use thiserror::Error;
7
+ use colored::Colorize;
8
+ use regex::Regex;
9
+
10
+ use crate::config::Config;
11
+ use crate::parser::HtmlParser;
12
+
13
+ // =============================================================================
14
+ // MIRROR_MODE: Copy asset files from source to output as-is, preserving
15
+ // directory structure. HTML files are compiled separately.
16
+ //
17
+ // Asset whitelist approach: Common web asset extensions automatically copied.
18
+ // Future refactoring: Replace with configurable include/exclude patterns,
19
+ // or a more sophisticated asset pipeline.
20
+ // =============================================================================
21
+ const MIRROR_EXTENSIONS: &[&str] = &[
22
+ "css", "js",
23
+ // Fonts
24
+ "ttf", "otf", "woff", "woff2", "eot",
25
+ // Images
26
+ "png", "jpg", "jpeg", "gif", "svg", "webp", "avif", "ico",
27
+ // Media
28
+ "mp4", "webm", "ogg", "mp3", "wav", "flac", "aac",
29
+ // Documents
30
+ "pdf",
31
+ // Data
32
+ "json", "xml", "csv",
33
+ ];
34
+
35
+ // Directories to skip when walking source
36
+ const SKIP_DIRECTORIES: &[&str] = &[
37
+ ".git",
38
+ ".claude",
39
+ "compiled", // Don't copy output into itself
40
+ "target", // Rust build artifacts
41
+ "test-results",
42
+ "playwright-report",
43
+ ];
44
+
45
+ #[derive(Error, Debug)]
46
+ pub enum CompileError {
47
+ #[error("Source directory not found: {0}")]
48
+ SourceNotFound(String),
49
+ #[error("Failed to create output directory: {0}")]
50
+ CreateDirError(String),
51
+ #[error("Failed to read file {path}: {source}")]
52
+ ReadError {
53
+ path: String,
54
+ #[source]
55
+ source: std::io::Error,
56
+ },
57
+ #[error("Failed to write file {path}: {source}")]
58
+ WriteError {
59
+ path: String,
60
+ #[source]
61
+ source: std::io::Error,
62
+ },
63
+ #[error("{file}:{line} - {message}")]
64
+ ValidationError {
65
+ file: String,
66
+ line: usize,
67
+ message: String,
68
+ },
69
+ #[error("Parse error: {0}")]
70
+ ParseError(#[from] crate::parser::html::ParseError),
71
+ #[error("Failed to execute command: {0}")]
72
+ CommandError(String),
73
+ #[error("Package manager not found")]
74
+ 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,
84
+ }
85
+
86
+ pub struct CompileStats {
87
+ pub files_compiled: usize,
88
+ pub files_copied: usize,
89
+ pub internal_components_unique: usize,
90
+ pub external_components_unique: usize,
91
+ pub internal_components_total: usize,
92
+ pub external_components_total: usize,
93
+ pub compile_time_ms: f64,
94
+ pub copy_time_ms: f64,
95
+ pub components_time_ms: f64,
96
+ pub validation_time_ms: Option<f64>,
97
+ pub node_modules_time_ms: Option<f64>,
98
+ pub package_manager: Option<String>,
99
+ pub node_modules_copied_as_is: bool,
100
+ pub components_as_is: bool,
101
+ }
102
+
103
+ #[derive(Debug, Clone, PartialEq)]
104
+ enum FileOperation {
105
+ Compiled,
106
+ Copied,
107
+ }
108
+
109
+ struct VerboseLogger {
110
+ operations: BTreeMap<String, Vec<(String, FileOperation)>>, // dir -> [(filename, operation)]
111
+ component_counts: HashMap<String, usize>, // component src -> count
112
+ component_children: HashMap<String, HashSet<String>>, // component src -> direct children
113
+ components_as_is: bool,
114
+ }
115
+
116
+ impl VerboseLogger {
117
+ fn new(components_as_is: bool) -> Self {
118
+ Self {
119
+ operations: BTreeMap::new(),
120
+ component_counts: HashMap::new(),
121
+ component_children: HashMap::new(),
122
+ components_as_is,
123
+ }
124
+ }
125
+
126
+ fn log_component_occurrence(&mut self, src: String) {
127
+ // Increment count for this component
128
+ *self.component_counts.entry(src).or_insert(0) += 1;
129
+ }
130
+
131
+ fn log_component_children(&mut self, src: String, children: Vec<String>) {
132
+ // Track children relationships
133
+ if !children.is_empty() {
134
+ self.component_children
135
+ .entry(src)
136
+ .or_insert_with(HashSet::new)
137
+ .extend(children);
138
+ }
139
+ }
140
+
141
+ fn log(&mut self, path: &Path, operation: FileOperation, source_root: &Path) {
142
+ let relative = path.strip_prefix(source_root).unwrap_or(path);
143
+ let dir = relative.parent()
144
+ .map(|p| p.to_string_lossy().to_string())
145
+ .unwrap_or_else(|| String::from(""));
146
+ let filename = relative.file_name()
147
+ .map(|f| f.to_string_lossy().to_string())
148
+ .unwrap_or_default();
149
+
150
+ self.operations
151
+ .entry(dir)
152
+ .or_insert_with(Vec::new)
153
+ .push((filename, operation));
154
+ }
155
+
156
+ fn print_components(&self) {
157
+ if self.components_as_is {
158
+ println!("\n{} (0 {}, 0 {}) - \"components-as-is\": true",
159
+ "Compiled components".bright_cyan(),
160
+ "internal".bright_green(),
161
+ "external".bright_magenta()
162
+ );
163
+ return;
164
+ }
165
+
166
+ if self.component_counts.is_empty() {
167
+ return;
168
+ }
169
+
170
+ // Count internal vs external
171
+ let mut internal_total = 0;
172
+ let mut external_total = 0;
173
+ for (src, count) in &self.component_counts {
174
+ if src.starts_with("http://") || src.starts_with("https://") {
175
+ external_total += count;
176
+ } else {
177
+ internal_total += count;
178
+ }
179
+ }
180
+
181
+ println!("\n{} ({} {}, {} {})",
182
+ "Compiled components".bright_cyan().bold(),
183
+ internal_total,
184
+ "internal".bright_green(),
185
+ external_total,
186
+ "external".bright_magenta()
187
+ );
188
+ println!();
189
+
190
+ // Sort components by count (descending) then alphabetically
191
+ let mut components: Vec<_> = self.component_counts.iter().collect();
192
+ components.sort_by(|(a_src, a_count), (b_src, b_count)| {
193
+ b_count.cmp(a_count).then_with(|| a_src.cmp(b_src))
194
+ });
195
+
196
+ // Find top-level components (those not referenced as children of others)
197
+ let all_children: HashSet<String> = self.component_children
198
+ .values()
199
+ .flat_map(|children| children.iter().cloned())
200
+ .collect();
201
+
202
+ // Print each top-level component with its tree
203
+ for (src, count) in &components {
204
+ // Only show as top-level if it's not a child of another component
205
+ if !all_children.contains(*src) {
206
+ let is_external = src.starts_with("http://") || src.starts_with("https://");
207
+
208
+ let display = if is_external {
209
+ format!("{} ({})", src, count).bright_magenta()
210
+ } else {
211
+ format!("{} ({})", src, count).bright_green()
212
+ };
213
+
214
+ println!("{}", display);
215
+
216
+ // Print children if any
217
+ if let Some(children) = self.component_children.get(*src) {
218
+ let mut children_vec: Vec<_> = children.iter().collect();
219
+ children_vec.sort();
220
+
221
+ for child in children_vec {
222
+ let is_external = child.starts_with("http://") || child.starts_with("https://");
223
+ let display = if is_external {
224
+ format!(" └─ {}", child).bright_magenta()
225
+ } else {
226
+ format!(" └─ {}", child).bright_green()
227
+ };
228
+ println!("{}", display);
229
+ }
230
+ }
231
+ }
232
+ }
233
+ }
234
+
235
+ fn print_compiled(&self) {
236
+ self.print_operations("Compiled HTML", FileOperation::Compiled);
237
+ }
238
+
239
+ fn print_copied(&self) {
240
+ self.print_operations("Copied files", FileOperation::Copied);
241
+ }
242
+
243
+ fn print_operations(&self, label: &str, op_type: FileOperation) {
244
+ let matching: Vec<_> = self.operations.iter()
245
+ .map(|(dir, files)| {
246
+ let filtered: Vec<_> = files.iter()
247
+ .filter(|(_, op)| {
248
+ match (&op_type, op) {
249
+ (FileOperation::Compiled, FileOperation::Compiled) => true,
250
+ (FileOperation::Copied, FileOperation::Copied) => true,
251
+ _ => false,
252
+ }
253
+ })
254
+ .map(|(name, _)| name.clone())
255
+ .collect();
256
+ (dir.clone(), filtered)
257
+ })
258
+ .filter(|(_, files)| !files.is_empty())
259
+ .collect();
260
+
261
+ if matching.is_empty() {
262
+ return;
263
+ }
264
+
265
+ let total_count: usize = matching.iter().map(|(_, files)| files.len()).sum();
266
+ println!("\n{} ({})", label.bright_cyan().bold(), total_count);
267
+ println!();
268
+
269
+ // Root files first
270
+ if let Some((_, files)) = matching.iter().find(|(dir, _)| dir.is_empty()) {
271
+ for file in files {
272
+ println!(" {}", file);
273
+ }
274
+ }
275
+
276
+ // Then directories hierarchically
277
+ self.print_directories(&matching, "", 1);
278
+ }
279
+
280
+ fn print_directories(&self, matching: &[(String, Vec<String>)], parent: &str, depth: usize) {
281
+ // Find all directories at this level
282
+ let mut dirs_at_level: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
283
+
284
+ for (dir, _) in matching {
285
+ if dir.is_empty() {
286
+ continue;
287
+ }
288
+
289
+ let remainder = if parent.is_empty() {
290
+ if dir.starts_with(parent) {
291
+ dir.as_str()
292
+ } else {
293
+ continue;
294
+ }
295
+ } else {
296
+ if dir.starts_with(parent) && dir.len() > parent.len() {
297
+ &dir[parent.len() + 1..] // +1 for the separator
298
+ } else {
299
+ continue;
300
+ }
301
+ };
302
+
303
+ if let Some(slash_pos) = remainder.find('/') {
304
+ dirs_at_level.insert(remainder[..slash_pos].to_string());
305
+ } else if !remainder.is_empty() {
306
+ dirs_at_level.insert(remainder.to_string());
307
+ }
308
+ }
309
+
310
+ // Print each directory
311
+ for dir_name in dirs_at_level {
312
+ let full_path = if parent.is_empty() {
313
+ dir_name.clone()
314
+ } else {
315
+ format!("{}/{}", parent, dir_name)
316
+ };
317
+
318
+ let indent = " ".repeat(depth);
319
+
320
+ // Count files directly in this directory (not subdirectories)
321
+ let file_count = matching.iter()
322
+ .find(|(d, _)| d == &full_path)
323
+ .map(|(_, files)| files.len())
324
+ .unwrap_or(0);
325
+
326
+ if file_count > 0 {
327
+ println!("{}{} ({})", indent, format!("/{}", dir_name).cyan(), file_count);
328
+ } else {
329
+ println!("{}{}", indent, format!("/{}", dir_name).cyan());
330
+ }
331
+
332
+ // Print files in this directory
333
+ if let Some((_, files)) = matching.iter().find(|(d, _)| d == &full_path) {
334
+ for file in files {
335
+ println!("{} {}", indent, file);
336
+ }
337
+ }
338
+
339
+ // Recurse into subdirectories
340
+ self.print_directories(matching, &full_path, depth + 1);
341
+ }
342
+ }
343
+ }
344
+
345
+ pub struct Compiler {
346
+ config: Config,
347
+ verbose: bool,
348
+ logger: Option<VerboseLogger>,
349
+ unique_components: HashSet<String>,
350
+ scanned_components: HashSet<String>,
351
+ external_component_cache: std::collections::HashMap<String, String>,
352
+ }
353
+
354
+ impl Compiler {
355
+ pub fn new(config: Config, verbose: bool) -> Self {
356
+ let logger = if verbose { Some(VerboseLogger::new(config.components_as_is)) } else { None };
357
+ Self {
358
+ config,
359
+ verbose,
360
+ logger,
361
+ unique_components: HashSet::new(),
362
+ scanned_components: HashSet::new(),
363
+ external_component_cache: HashMap::new(),
364
+ }
365
+ }
366
+
367
+ pub fn compile(&mut self) -> Result<CompileStats, CompileError> {
368
+ let mut stats = CompileStats {
369
+ files_compiled: 0,
370
+ files_copied: 0,
371
+ internal_components_unique: 0,
372
+ external_components_unique: 0,
373
+ internal_components_total: 0,
374
+ external_components_total: 0,
375
+ compile_time_ms: 0.0,
376
+ copy_time_ms: 0.0,
377
+ components_time_ms: 0.0,
378
+ validation_time_ms: None,
379
+ node_modules_time_ms: None,
380
+ package_manager: None,
381
+ node_modules_copied_as_is: false,
382
+ components_as_is: self.config.components_as_is,
383
+ };
384
+
385
+ // Validate source exists
386
+ if !self.config.source.exists() {
387
+ return Err(CompileError::SourceNotFound(
388
+ self.config.source.display().to_string(),
389
+ ));
390
+ }
391
+
392
+ // Load components for HTML compilation (needed for validation)
393
+ let mut parser = HtmlParser::new(self.config.components_path());
394
+ parser.load_elements()?;
395
+
396
+ // Validate all components upfront (before creating output directory or touching filesystem)
397
+ // This happens AFTER parser.load_elements() so custom tags can be transformed to <component> tags
398
+ if !self.config.components_as_is {
399
+ let validation_start = Instant::now();
400
+ if self.verbose {
401
+ println!("\nValidating components...");
402
+ }
403
+ self.validate_all_components(&parser)?;
404
+ stats.validation_time_ms = Some(validation_start.elapsed().as_secs_f64() * 1000.0);
405
+ if self.verbose {
406
+ println!(" All components validated successfully");
407
+ }
408
+ }
409
+
410
+ // Create output directory
411
+ if !self.config.output.exists() {
412
+ fs::create_dir_all(&self.config.output).map_err(|_| {
413
+ CompileError::CreateDirError(self.config.output.display().to_string())
414
+ })?;
415
+ if self.verbose {
416
+ println!(" Created output directory: {}", self.config.output.display());
417
+ }
418
+ }
419
+
420
+
421
+ // Track compile and copy times separately
422
+ let compile_start = Instant::now();
423
+ self.process_directory(&self.config.source.clone(), &parser, "", &mut stats)?;
424
+ let process_time = compile_start.elapsed();
425
+
426
+ // Print verbose output after processing (components first, then HTML, then copied)
427
+ if let Some(ref logger) = self.logger {
428
+ logger.print_components();
429
+ logger.print_compiled();
430
+ logger.print_copied();
431
+ }
432
+
433
+ // Calculate unique components after processing all files
434
+ let internal_unique = self.unique_components.iter()
435
+ .filter(|src| !src.starts_with("http://") && !src.starts_with("https://"))
436
+ .count();
437
+ let external_unique = self.unique_components.iter()
438
+ .filter(|src| src.starts_with("http://") || src.starts_with("https://"))
439
+ .count();
440
+
441
+ stats.internal_components_unique = internal_unique;
442
+ stats.external_components_unique = external_unique;
443
+
444
+ // Calculate times based on counts (rough estimate)
445
+ // Component operations are part of compile time but tracked separately for user visibility
446
+ let total_files = stats.files_compiled + stats.files_copied;
447
+ let total_components = stats.internal_components_total + stats.external_components_total;
448
+
449
+ if total_files > 0 {
450
+ let total_ms = process_time.as_secs_f64() * 1000.0;
451
+
452
+ if total_components > 0 {
453
+ // Component processing is slowest, compile is medium, copy is fastest
454
+ // Rough weights: component=3, compile=2, copy=1
455
+ let total_weight = (total_components * 3 + stats.files_compiled * 2 + stats.files_copied) as f64;
456
+ stats.components_time_ms = (total_ms * (total_components * 3) as f64) / total_weight;
457
+ stats.compile_time_ms = (total_ms * (stats.files_compiled * 2) as f64) / total_weight;
458
+ stats.copy_time_ms = (total_ms * stats.files_copied as f64) / total_weight;
459
+ } else {
460
+ // No component operations, just split between compile and copy
461
+ stats.compile_time_ms = (total_ms * stats.files_compiled as f64) / total_files as f64;
462
+ stats.copy_time_ms = (total_ms * stats.files_copied as f64) / total_files as f64;
463
+ }
464
+ }
465
+
466
+ // Handle node_modules after successful compilation
467
+ if self.config.working_dir.join("node_modules").exists() {
468
+ if self.config.node_modules_as_is {
469
+ // Copy node_modules as-is (no install, just copy)
470
+ let nm_start = Instant::now();
471
+ self.copy_node_modules_as_is()?;
472
+ stats.node_modules_time_ms = Some(nm_start.elapsed().as_secs_f64() * 1000.0);
473
+ stats.node_modules_copied_as_is = true;
474
+
475
+ if self.verbose {
476
+ println!("\n Copied node_modules as-is");
477
+ }
478
+ } else {
479
+ // Use production-only strategy (runs install commands)
480
+ let pkg_manager = detect_package_manager(&self.config.working_dir).ok();
481
+ stats.package_manager = pkg_manager.clone();
482
+
483
+ let nm_start = Instant::now();
484
+ self.copy_production_node_modules()?;
485
+ stats.node_modules_time_ms = Some(nm_start.elapsed().as_secs_f64() * 1000.0);
486
+
487
+ if self.verbose {
488
+ println!(" Copied production dependencies");
489
+ }
490
+ }
491
+ }
492
+
493
+ Ok(stats)
494
+ }
495
+
496
+ /// Recursively walk source, compile HTML, copy CSS/JS (MIRROR_MODE)
497
+ fn process_directory(
498
+ &mut self,
499
+ dir: &Path,
500
+ parser: &HtmlParser,
501
+ relative_path: &str,
502
+ stats: &mut CompileStats,
503
+ ) -> Result<(), CompileError> {
504
+ let entries = fs::read_dir(dir).map_err(|e| CompileError::ReadError {
505
+ path: dir.display().to_string(),
506
+ source: e,
507
+ })?;
508
+
509
+ for entry in entries.flatten() {
510
+ let path = entry.path();
511
+ let file_name = path.file_name().unwrap().to_str().unwrap();
512
+
513
+ if path.is_dir() {
514
+ // Skip special directories
515
+ if SKIP_DIRECTORIES.contains(&file_name) {
516
+ continue;
517
+ }
518
+
519
+ // Skip node_modules (handled separately after compilation)
520
+ if file_name == "node_modules" {
521
+ continue;
522
+ }
523
+
524
+ // Handle components directory based on components_as_is flag
525
+ if file_name == self.config.components {
526
+ if self.config.components_as_is {
527
+ // Copy components directory to output for runtime
528
+ let new_relative = if relative_path.is_empty() {
529
+ file_name.to_string()
530
+ } else {
531
+ format!("{}/{}", relative_path, file_name)
532
+ };
533
+ self.copy_directory(&path, &new_relative, stats)?;
534
+ }
535
+ // Skip further processing (don't recurse into components)
536
+ continue;
537
+ }
538
+
539
+ // Recurse into subdirectory
540
+ let new_relative = if relative_path.is_empty() {
541
+ file_name.to_string()
542
+ } else {
543
+ format!("{}/{}", relative_path, file_name)
544
+ };
545
+
546
+ self.process_directory(&path, parser, &new_relative, stats)?;
547
+ } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
548
+ match ext {
549
+ "html" => {
550
+ let (internal, external, component_srcs) = self.compile_html_file(&path, parser, relative_path)?;
551
+ stats.files_compiled += 1;
552
+ stats.internal_components_total += internal;
553
+ stats.external_components_total += external;
554
+
555
+ // Track unique components
556
+ for src in &component_srcs {
557
+ self.unique_components.insert(src.clone());
558
+ }
559
+
560
+ // Build component relationships and count occurrences before borrowing logger
561
+ let (component_relationships, all_srcs) = if self.logger.is_some() && !component_srcs.is_empty() {
562
+ let relationships = self.build_component_relationships(&component_srcs);
563
+ (Some(relationships), Some(component_srcs))
564
+ } else {
565
+ (None, None)
566
+ };
567
+
568
+ if let Some(ref mut logger) = self.logger {
569
+ logger.log(&path, FileOperation::Compiled, &self.config.source);
570
+
571
+ // Log each component occurrence (for counting)
572
+ if let Some(all_srcs) = all_srcs {
573
+ for src in all_srcs {
574
+ logger.log_component_occurrence(src);
575
+ }
576
+ }
577
+
578
+ // Log unique relationships (for tree structure)
579
+ if let Some(relationships) = component_relationships {
580
+ for (src, children) in relationships {
581
+ logger.log_component_children(src, children);
582
+ }
583
+ }
584
+ }
585
+ }
586
+ // MIRROR_MODE: Copy CSS/JS as-is
587
+ ext if MIRROR_EXTENSIONS.contains(&ext) => {
588
+ self.copy_file(&path, relative_path)?;
589
+ stats.files_copied += 1;
590
+ if let Some(ref mut logger) = self.logger {
591
+ logger.log(&path, FileOperation::Copied, &self.config.source);
592
+ }
593
+ }
594
+ _ => {}
595
+ }
596
+ }
597
+ }
598
+
599
+ Ok(())
600
+ }
601
+
602
+ fn compile_html_file(
603
+ &self,
604
+ path: &Path,
605
+ parser: &HtmlParser,
606
+ relative_path: &str,
607
+ ) -> Result<(usize, usize, Vec<String>), CompileError> {
608
+ let content = fs::read_to_string(path).map_err(|e| CompileError::ReadError {
609
+ path: path.display().to_string(),
610
+ source: e,
611
+ })?;
612
+
613
+ // Validate if requested
614
+ if self.config.validate {
615
+ self.validate_html(&content, path)?;
616
+ }
617
+
618
+ // Count <component src="..."> instances before processing (only if not components_as_is)
619
+ let (internal_count, external_count, component_srcs) = if !self.config.components_as_is {
620
+ self.extract_components(&content)
621
+ } else {
622
+ (0, 0, Vec::new())
623
+ };
624
+
625
+ // Compile: transform custom tags to <component>, inline if needed, accessibility transform
626
+ let processed = parser.process_html_with_cache(
627
+ &content,
628
+ self.config.accessibility,
629
+ &self.config.exclude_tags,
630
+ self.config.components_as_is,
631
+ &self.config.components,
632
+ &self.external_component_cache,
633
+ );
634
+
635
+ // Minify if requested
636
+ let output = if self.config.minify {
637
+ minify_html(&processed)
638
+ } else {
639
+ processed
640
+ };
641
+
642
+ // Write to output
643
+ let output_path = self.get_output_path(path, relative_path)?;
644
+ fs::write(&output_path, output).map_err(|e| CompileError::WriteError {
645
+ path: output_path.display().to_string(),
646
+ source: e,
647
+ })?;
648
+
649
+ // Return component counts and src list
650
+ Ok((internal_count, external_count, component_srcs))
651
+ }
652
+
653
+ fn extract_components(&self, content: &str) -> (usize, usize, Vec<String>) {
654
+ let mut internal = 0;
655
+ let mut external = 0;
656
+ let mut srcs = Vec::new();
657
+
658
+ // Simple regex-based extraction of <component src="..."> tags
659
+ let re = Regex::new(r#"<component[^>]+src\s*=\s*["']([^"']+)["']"#).unwrap();
660
+ for cap in re.captures_iter(content) {
661
+ if let Some(src) = cap.get(1) {
662
+ let src_str = src.as_str().to_string();
663
+ if src_str.starts_with("http://") || src_str.starts_with("https://") {
664
+ external += 1;
665
+ } else {
666
+ internal += 1;
667
+ }
668
+ srcs.push(src_str);
669
+ }
670
+ }
671
+
672
+ (internal, external, srcs)
673
+ }
674
+
675
+ fn build_component_relationships(&mut self, component_srcs: &[String]) -> Vec<(String, Vec<String>)> {
676
+ let mut relationships = Vec::new();
677
+ let mut visited = HashSet::new();
678
+
679
+ for src in component_srcs {
680
+ self.collect_component_children(src, &mut relationships, &mut visited);
681
+ }
682
+
683
+ relationships
684
+ }
685
+
686
+ fn collect_component_children(&mut self, component_src: &str, relationships: &mut Vec<(String, Vec<String>)>, visited: &mut HashSet<String>) {
687
+ if visited.contains(component_src) {
688
+ return;
689
+ }
690
+ visited.insert(component_src.to_string());
691
+
692
+ // Read component content
693
+ let content = if component_src.starts_with("http://") || component_src.starts_with("https://") {
694
+ match self.fetch_external_component(component_src) {
695
+ Ok(c) => c,
696
+ Err(_) => return,
697
+ }
698
+ } else {
699
+ match self.resolve_component_path(component_src) {
700
+ Ok(path) => match fs::read_to_string(&path) {
701
+ Ok(c) => c,
702
+ Err(_) => return,
703
+ },
704
+ Err(_) => return,
705
+ }
706
+ };
707
+
708
+ // Extract immediate children
709
+ let children = self.extract_component_srcs(&content);
710
+
711
+ // Add this component and its children to relationships
712
+ relationships.push((component_src.to_string(), children.clone()));
713
+
714
+ // Recursively collect children's relationships
715
+ for child in children {
716
+ self.collect_component_children(&child, relationships, visited);
717
+ }
718
+ }
719
+
720
+
721
+ /// MIRROR_MODE: Copy file to output, preserving relative path
722
+ fn copy_file(&self, path: &Path, relative_path: &str) -> Result<(), CompileError> {
723
+ let output_path = self.get_output_path(path, relative_path)?;
724
+
725
+ fs::copy(path, &output_path).map_err(|e| CompileError::WriteError {
726
+ path: output_path.display().to_string(),
727
+ source: e,
728
+ })?;
729
+
730
+ Ok(())
731
+ }
732
+
733
+ fn validate_all_components(&mut self, parser: &HtmlParser) -> Result<(), CompileError> {
734
+ let mut errors = Vec::new();
735
+
736
+ // Find all HTML files in source directory
737
+ let html_files = self.find_all_html_files(&self.config.source)?;
738
+
739
+ // Scan each HTML file for components
740
+ for html_file in html_files {
741
+ let content = match fs::read_to_string(&html_file) {
742
+ Ok(c) => c,
743
+ Err(e) => {
744
+ errors.push(ComponentError {
745
+ component_src: html_file.display().to_string(),
746
+ referenced_in: "source".to_string(),
747
+ error_message: format!("Failed to read file: {}", e),
748
+ });
749
+ continue;
750
+ }
751
+ };
752
+
753
+ // Transform custom tags to <component> tags (e.g., <headline> → <component src="/components/Headline.html">)
754
+ // Use process_html with elements_as_is=true to transform without inlining
755
+ let transformed = parser.process_html(
756
+ &content,
757
+ false, // accessibility
758
+ &[], // exclude_tags
759
+ true, // elements_as_is (don't inline, just transform)
760
+ &self.config.components,
761
+ );
762
+
763
+ // Extract components from transformed content
764
+ let components = self.extract_component_srcs(&transformed);
765
+ for component_src in components {
766
+ self.validate_component_recursive(
767
+ &component_src,
768
+ &html_file.display().to_string(),
769
+ &mut errors,
770
+ parser,
771
+ );
772
+ }
773
+ }
774
+
775
+ // If any errors, return them all
776
+ if !errors.is_empty() {
777
+ return Err(CompileError::ComponentValidationFailed(errors));
778
+ }
779
+
780
+ Ok(())
781
+ }
782
+
783
+ fn validate_component_recursive(
784
+ &mut self,
785
+ component_src: &str,
786
+ referenced_in: &str,
787
+ errors: &mut Vec<ComponentError>,
788
+ parser: &HtmlParser,
789
+ ) {
790
+ // Skip if we've already scanned this component
791
+ if self.scanned_components.contains(component_src) {
792
+ return;
793
+ }
794
+ self.scanned_components.insert(component_src.to_string());
795
+
796
+ // Check if it's external (http:// or https://)
797
+ if component_src.starts_with("http://") || component_src.starts_with("https://") {
798
+ // Fetch external component
799
+ match self.fetch_external_component(component_src) {
800
+ Ok(content) => {
801
+ // Transform custom tags in fetched content
802
+ let transformed = parser.process_html(
803
+ &content,
804
+ false,
805
+ &[],
806
+ true,
807
+ &self.config.components,
808
+ );
809
+
810
+ // Recursively scan for nested components
811
+ let nested = self.extract_component_srcs(&transformed);
812
+ for nested_src in nested {
813
+ self.validate_component_recursive(&nested_src, component_src, errors, parser);
814
+ }
815
+ }
816
+ Err(e) => {
817
+ errors.push(ComponentError {
818
+ component_src: component_src.to_string(),
819
+ referenced_in: referenced_in.to_string(),
820
+ error_message: e,
821
+ });
822
+ }
823
+ }
824
+ } else {
825
+ // Internal component - resolve path
826
+ let component_path = self.resolve_component_path(component_src);
827
+ match component_path {
828
+ Ok(path) => {
829
+ // Read component file
830
+ match fs::read_to_string(&path) {
831
+ Ok(content) => {
832
+ // Transform custom tags in component content
833
+ let transformed = parser.process_html(
834
+ &content,
835
+ false,
836
+ &[],
837
+ true,
838
+ &self.config.components,
839
+ );
840
+
841
+ // Recursively scan for nested components
842
+ let nested = self.extract_component_srcs(&transformed);
843
+ for nested_src in nested {
844
+ self.validate_component_recursive(&nested_src, component_src, errors, parser);
845
+ }
846
+ }
847
+ Err(e) => {
848
+ errors.push(ComponentError {
849
+ component_src: component_src.to_string(),
850
+ referenced_in: referenced_in.to_string(),
851
+ error_message: format!("Failed to read component file: {}", e),
852
+ });
853
+ }
854
+ }
855
+ }
856
+ Err(e) => {
857
+ errors.push(ComponentError {
858
+ component_src: component_src.to_string(),
859
+ referenced_in: referenced_in.to_string(),
860
+ error_message: e,
861
+ });
862
+ }
863
+ }
864
+ }
865
+ }
866
+
867
+ fn fetch_external_component(&mut self, url: &str) -> Result<String, String> {
868
+ // Check cache first
869
+ if let Some(cached) = self.external_component_cache.get(url) {
870
+ return Ok(cached.clone());
871
+ }
872
+
873
+ // Fetch from URL
874
+ match reqwest::blocking::get(url) {
875
+ Ok(response) => {
876
+ if !response.status().is_success() {
877
+ return Err(format!("HTTP {} - {}", response.status().as_u16(), response.status().canonical_reason().unwrap_or("Unknown")));
878
+ }
879
+ match response.text() {
880
+ Ok(content) => {
881
+ // Cache the result
882
+ self.external_component_cache.insert(url.to_string(), content.clone());
883
+ Ok(content)
884
+ }
885
+ Err(e) => Err(format!("Failed to read response body: {}", e)),
886
+ }
887
+ }
888
+ Err(e) => Err(format!("Failed to fetch: {}", e)),
889
+ }
890
+ }
891
+
892
+ fn resolve_component_path(&self, component_src: &str) -> Result<PathBuf, String> {
893
+ // Normalize path (remove leading ./ or /)
894
+ let normalized = component_src.trim_start_matches("./").trim_start_matches('/');
895
+
896
+ // Try relative to source root
897
+ let path = self.config.source.join(normalized);
898
+ if path.exists() {
899
+ return Ok(path);
900
+ }
901
+
902
+ Err(format!("Component file not found: {}", component_src))
903
+ }
904
+
905
+ fn extract_component_srcs(&self, content: &str) -> Vec<String> {
906
+ let mut srcs = Vec::new();
907
+ let re = Regex::new(r#"<component[^>]+src\s*=\s*["']([^"']+)["']"#).unwrap();
908
+ for cap in re.captures_iter(content) {
909
+ if let Some(src) = cap.get(1) {
910
+ srcs.push(src.as_str().to_string());
911
+ }
912
+ }
913
+ srcs
914
+ }
915
+
916
+ fn find_all_html_files(&self, dir: &Path) -> Result<Vec<PathBuf>, CompileError> {
917
+ let mut html_files = Vec::new();
918
+ self.find_html_files_recursive(dir, &mut html_files)?;
919
+ Ok(html_files)
920
+ }
921
+
922
+ fn find_html_files_recursive(&self, dir: &Path, files: &mut Vec<PathBuf>) -> Result<(), CompileError> {
923
+ if !dir.is_dir() {
924
+ return Ok(());
925
+ }
926
+
927
+ let entries = match fs::read_dir(dir) {
928
+ Ok(e) => e,
929
+ Err(_) => return Ok(()), // Skip directories we can't read
930
+ };
931
+
932
+ for entry in entries {
933
+ let entry = match entry {
934
+ Ok(e) => e,
935
+ Err(_) => continue,
936
+ };
937
+
938
+ let path = entry.path();
939
+ let file_name = entry.file_name();
940
+ let file_name_str = file_name.to_string_lossy();
941
+
942
+ // Skip hidden files and specific directories
943
+ if file_name_str.starts_with('.') || SKIP_DIRECTORIES.contains(&file_name_str.as_ref()) {
944
+ continue;
945
+ }
946
+
947
+ // Skip components directory during initial scan (we'll validate them when referenced)
948
+ if file_name_str == self.config.components {
949
+ continue;
950
+ }
951
+
952
+ if path.is_dir() {
953
+ self.find_html_files_recursive(&path, files)?;
954
+ } else if path.extension().and_then(|e| e.to_str()) == Some("html") {
955
+ files.push(path);
956
+ }
957
+ }
958
+
959
+ Ok(())
960
+ }
961
+
962
+ fn get_output_path(&self, path: &Path, relative_path: &str) -> Result<PathBuf, CompileError> {
963
+ let file_name = path.file_name().unwrap();
964
+
965
+ let output_path = if relative_path.is_empty() {
966
+ self.config.output.join(file_name)
967
+ } else {
968
+ let output_subdir = self.config.output.join(relative_path);
969
+ fs::create_dir_all(&output_subdir).map_err(|_| {
970
+ CompileError::CreateDirError(output_subdir.display().to_string())
971
+ })?;
972
+ output_subdir.join(file_name)
973
+ };
974
+
975
+ Ok(output_path)
976
+ }
977
+
978
+ fn validate_html(&self, content: &str, path: &Path) -> Result<(), CompileError> {
979
+ let mut line_num = 1;
980
+
981
+ for line in content.lines() {
982
+ let quote_count = line.matches('"').count();
983
+ if quote_count % 2 != 0 {
984
+ return Err(CompileError::ValidationError {
985
+ file: path.display().to_string(),
986
+ line: line_num,
987
+ message: "Unclosed quote".to_string(),
988
+ });
989
+ }
990
+ line_num += 1;
991
+ }
992
+
993
+ Ok(())
994
+ }
995
+
996
+ /// Copy an entire directory recursively
997
+ fn copy_directory(
998
+ &mut self,
999
+ dir: &Path,
1000
+ relative_path: &str,
1001
+ stats: &mut CompileStats,
1002
+ ) -> Result<(), CompileError> {
1003
+ let entries = fs::read_dir(dir).map_err(|e| CompileError::ReadError {
1004
+ path: dir.display().to_string(),
1005
+ source: e,
1006
+ })?;
1007
+
1008
+ for entry in entries.flatten() {
1009
+ let path = entry.path();
1010
+ let file_name = path.file_name().unwrap().to_str().unwrap();
1011
+
1012
+ if path.is_dir() {
1013
+ let new_relative = format!("{}/{}", relative_path, file_name);
1014
+ self.copy_directory(&path, &new_relative, stats)?;
1015
+ } else {
1016
+ self.copy_file(&path, relative_path)?;
1017
+ stats.files_copied += 1;
1018
+ if let Some(ref mut logger) = self.logger {
1019
+ logger.log(&path, FileOperation::Copied, &self.config.source);
1020
+ }
1021
+ }
1022
+ }
1023
+
1024
+ Ok(())
1025
+ }
1026
+
1027
+ /// Copy node_modules as-is to output
1028
+ fn copy_node_modules_as_is(&self) -> Result<(), CompileError> {
1029
+ let src = self.config.working_dir.join("node_modules");
1030
+ let dest = self.config.output.join("node_modules");
1031
+
1032
+ // Remove existing node_modules in output if it exists
1033
+ if dest.exists() {
1034
+ fs::remove_dir_all(&dest).map_err(|e| CompileError::WriteError {
1035
+ path: dest.display().to_string(),
1036
+ source: e,
1037
+ })?;
1038
+ }
1039
+
1040
+ // Copy recursively
1041
+ copy_dir_recursive(&src, &dest)?;
1042
+
1043
+ Ok(())
1044
+ }
1045
+
1046
+ /// Install production dependencies directly into output directory
1047
+ fn copy_production_node_modules(&self) -> Result<(), CompileError> {
1048
+ let pkg_manager = detect_package_manager(&self.config.working_dir)?;
1049
+
1050
+ if self.verbose {
1051
+ println!("\n Detected package manager: {}", pkg_manager);
1052
+ println!(" Installing production dependencies to output...");
1053
+ }
1054
+
1055
+ // 1. Copy package.json to output directory
1056
+ let package_json_src = self.config.working_dir.join("package.json");
1057
+ let package_json_dest = self.config.output.join("package.json");
1058
+
1059
+ fs::copy(&package_json_src, &package_json_dest).map_err(|e| CompileError::WriteError {
1060
+ path: package_json_dest.display().to_string(),
1061
+ source: e,
1062
+ })?;
1063
+
1064
+ // 2. Copy lockfile if it exists (needed for reproducible installs)
1065
+ let lockfile_name = match pkg_manager.as_str() {
1066
+ "bun" => "bun.lockb",
1067
+ "pnpm" => "pnpm-lock.yaml",
1068
+ "yarn" => "yarn.lock",
1069
+ "npm" => "package-lock.json",
1070
+ _ => "",
1071
+ };
1072
+
1073
+ if !lockfile_name.is_empty() {
1074
+ let lockfile_src = self.config.working_dir.join(lockfile_name);
1075
+ let lockfile_dest = self.config.output.join(lockfile_name);
1076
+ if lockfile_src.exists() {
1077
+ fs::copy(&lockfile_src, &lockfile_dest).ok();
1078
+ }
1079
+ }
1080
+
1081
+ // 3. Install production dependencies directly in output directory
1082
+ run_command(&pkg_manager, &["install", "--production"], &self.config.output)?;
1083
+
1084
+ // 4. Cleanup: remove package.json and lockfile from output
1085
+ fs::remove_file(&package_json_dest).ok();
1086
+ if !lockfile_name.is_empty() {
1087
+ let lockfile_dest = self.config.output.join(lockfile_name);
1088
+ fs::remove_file(&lockfile_dest).ok();
1089
+ }
1090
+
1091
+ if self.verbose {
1092
+ println!(" Installed production dependencies to output");
1093
+ }
1094
+
1095
+ Ok(())
1096
+ }
1097
+ }
1098
+
1099
+ /// Detect package manager from lockfiles
1100
+ fn detect_package_manager(dir: &Path) -> Result<String, CompileError> {
1101
+ if dir.join("bun.lockb").exists() {
1102
+ return Ok("bun".to_string());
1103
+ }
1104
+ if dir.join("pnpm-lock.yaml").exists() {
1105
+ return Ok("pnpm".to_string());
1106
+ }
1107
+ if dir.join("yarn.lock").exists() {
1108
+ return Ok("yarn".to_string());
1109
+ }
1110
+ if dir.join("package-lock.json").exists() {
1111
+ return Ok("npm".to_string());
1112
+ }
1113
+
1114
+ Err(CompileError::PackageManagerNotFound)
1115
+ }
1116
+
1117
+ /// Run a package manager command
1118
+ fn run_command(pkg_manager: &str, args: &[&str], cwd: &Path) -> Result<(), CompileError> {
1119
+ let output = Command::new(pkg_manager)
1120
+ .args(args)
1121
+ .current_dir(cwd)
1122
+ .output()
1123
+ .map_err(|e| CompileError::CommandError(format!("{} not found: {}", pkg_manager, e)))?;
1124
+
1125
+ if !output.status.success() {
1126
+ let stderr = String::from_utf8_lossy(&output.stderr);
1127
+ return Err(CompileError::CommandError(format!(
1128
+ "{} {} failed: {}",
1129
+ pkg_manager,
1130
+ args.join(" "),
1131
+ stderr
1132
+ )));
1133
+ }
1134
+
1135
+ Ok(())
1136
+ }
1137
+
1138
+ /// Recursively copy a directory
1139
+ fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), CompileError> {
1140
+ if !dest.exists() {
1141
+ fs::create_dir_all(dest).map_err(|_| CompileError::CreateDirError(dest.display().to_string()))?;
1142
+ }
1143
+
1144
+ for entry in fs::read_dir(src).map_err(|e| CompileError::ReadError {
1145
+ path: src.display().to_string(),
1146
+ source: e,
1147
+ })? {
1148
+ let entry = entry.map_err(|e| CompileError::ReadError {
1149
+ path: src.display().to_string(),
1150
+ source: e,
1151
+ })?;
1152
+ let path = entry.path();
1153
+ let file_name = entry.file_name();
1154
+ let dest_path = dest.join(&file_name);
1155
+
1156
+ if path.is_dir() {
1157
+ copy_dir_recursive(&path, &dest_path)?;
1158
+ } else {
1159
+ fs::copy(&path, &dest_path).map_err(|e| CompileError::WriteError {
1160
+ path: dest_path.display().to_string(),
1161
+ source: e,
1162
+ })?;
1163
+ }
1164
+ }
1165
+
1166
+ Ok(())
1167
+ }
1168
+
1169
+ /// Basic HTML minification
1170
+ fn minify_html(html: &str) -> String {
1171
+ let mut result = String::with_capacity(html.len());
1172
+ let mut in_pre = false;
1173
+ let mut last_was_space = false;
1174
+
1175
+ for line in html.lines() {
1176
+ let trimmed = line.trim();
1177
+
1178
+ if trimmed.contains("<pre") {
1179
+ in_pre = true;
1180
+ }
1181
+ if trimmed.contains("</pre>") {
1182
+ in_pre = false;
1183
+ }
1184
+
1185
+ if in_pre {
1186
+ result.push_str(line);
1187
+ result.push('\n');
1188
+ } else {
1189
+ for c in trimmed.chars() {
1190
+ if c.is_whitespace() {
1191
+ if !last_was_space {
1192
+ result.push(' ');
1193
+ last_was_space = true;
1194
+ }
1195
+ } else {
1196
+ result.push(c);
1197
+ last_was_space = false;
1198
+ }
1199
+ }
1200
+ }
1201
+ }
1202
+
1203
+ let result = regex::Regex::new(r">\s+<")
1204
+ .unwrap()
1205
+ .replace_all(&result, "><")
1206
+ .to_string();
1207
+
1208
+ result.trim().to_string()
1209
+ }