@ape-egg/vibe-compiler 3.0.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.
@@ -0,0 +1,2985 @@
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
+ use glob::Pattern;
10
+ use rayon::prelude::*;
11
+ use serde_json::{Map, Value};
12
+
13
+ use crate::config::Config;
14
+ use crate::parser::HtmlParser;
15
+
16
+ /// Map an output-relative HTML path to the URL path the runtime resolves a
17
+ /// manifest from. Strips the `root` dir (served at /) and collapses dynamic
18
+ /// `$param` segments to a `$` token, e.g. with root `pages`:
19
+ /// pages/armory.html -> armory.html
20
+ /// pages/the-arena/$id.html -> the-arena/$.html
21
+ fn manifest_url_path(relative_path: &str, root: Option<&str>) -> String {
22
+ let stripped = match root {
23
+ Some(r) if relative_path == r => "",
24
+ Some(r) if relative_path.starts_with(&format!("{}/", r)) => &relative_path[r.len() + 1..],
25
+ _ => relative_path,
26
+ };
27
+
28
+ stripped
29
+ .split('/')
30
+ .map(|seg| match seg.strip_prefix('$') {
31
+ // $id.html -> $.html ; $id -> $
32
+ Some(rest) => match rest.find('.') {
33
+ Some(dot) => format!("${}", &rest[dot..]),
34
+ None => "$".to_string(),
35
+ },
36
+ None => seg.to_string(),
37
+ })
38
+ .collect::<Vec<_>>()
39
+ .join("/")
40
+ }
41
+
42
+ /// Write via a same-directory temp file + rename, so no reader — the dev
43
+ /// server, the browser, another compiler process — can ever observe a
44
+ /// partially-written file. Rename is atomic on POSIX; the temp name carries the
45
+ /// pid so two processes writing the same target never share a temp file.
46
+ pub(crate) fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
47
+ let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("out");
48
+ let tmp = path.with_file_name(format!(".{}.{}.vibe-tmp", file_name, std::process::id()));
49
+ fs::write(&tmp, contents)?;
50
+ fs::rename(&tmp, path)
51
+ }
52
+
53
+ // =============================================================================
54
+ // MIRROR_MODE: Copy asset files from source to output as-is, preserving
55
+ // directory structure. HTML files are compiled separately.
56
+ //
57
+ // Asset whitelist approach: Common web asset extensions automatically copied.
58
+ // Future refactoring: Replace with configurable include/exclude patterns,
59
+ // or a more sophisticated asset pipeline.
60
+ // =============================================================================
61
+ // FOUC prevention class/attribute name (matches runtime/constants.js)
62
+ // Can be used as class (.vibe-fouc) or attribute ([vibe-fouc])
63
+ const FOUC_CLASS_OR_ATTR: &str = "vibe-fouc";
64
+
65
+ const MIRROR_EXTENSIONS: &[&str] = &[
66
+ "css", "js",
67
+ // Fonts
68
+ "ttf", "otf", "woff", "woff2", "eot",
69
+ // Images
70
+ "png", "jpg", "jpeg", "gif", "svg", "webp", "avif", "ico",
71
+ // Media
72
+ "mp4", "webm", "ogg", "mp3", "wav", "flac", "aac",
73
+ // Documents
74
+ "pdf",
75
+ // Data
76
+ "json", "xml", "csv",
77
+ ];
78
+
79
+ // Built-in files and directories to skip when walking source (supports glob
80
+ // patterns). User-supplied `skipFiles` are appended to these in Config::load,
81
+ // so the effective list lives on `config.skip_files` and is passed into
82
+ // should_skip_path() at every call site.
83
+ // Note: Output directory is checked dynamically (not hardcoded here)
84
+ // Note: Dotfiles are handled by starts_with('.') check in should_skip_path()
85
+ pub const SKIP_FILES: &[&str] = &[
86
+ "target", // Rust build artifacts
87
+ "tests", // Test files
88
+ "test-results",
89
+ "playwright-report",
90
+ "node_modules", // Skip node_modules during scanning
91
+ "**/*.test.js", // Test files
92
+ "**/*.config.js", // Config files
93
+ "**/*.test.ts", // Test files
94
+ "**/*.config.ts", // Config files
95
+ ];
96
+
97
+ /// Check if a path should be skipped based on the effective skip patterns
98
+ /// (built-in SKIP_FILES + user `skipFiles`, merged in Config::load).
99
+ pub fn should_skip_path(path: &Path, name: &str, patterns: &[String]) -> bool {
100
+ // Check if name starts with dot (dotfiles/directories)
101
+ if name.starts_with('.') {
102
+ return true;
103
+ }
104
+
105
+ // Check exact name match (for directories and simple filenames)
106
+ if patterns.iter().any(|p| p == name) {
107
+ return true;
108
+ }
109
+
110
+ // Check glob patterns (e.g., **/*.test.js)
111
+ for pattern_str in patterns {
112
+ if pattern_str.contains('*') {
113
+ if let Ok(pattern) = Pattern::new(pattern_str) {
114
+ // Try matching against just the filename
115
+ if pattern.matches(name) {
116
+ return true;
117
+ }
118
+ // Try matching against the full path
119
+ if let Some(path_str) = path.to_str() {
120
+ if pattern.matches(path_str) {
121
+ return true;
122
+ }
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ false
129
+ }
130
+
131
+ /// Recursively gather JS sources that could write `$` state: every `.js` module
132
+ /// (full text — also a candidate for the global `vibe()` call) plus, from each
133
+ /// `.html` file, every inline `<script>` body and `on*` handler body. Skips the
134
+ /// compiled output directory and anything `should_skip_path` excludes.
135
+ fn collect_state_sources(
136
+ dir: &Path,
137
+ output_canon: Option<&Path>,
138
+ skip: &[String],
139
+ sources: &mut Vec<String>,
140
+ js_paths: &mut Vec<PathBuf>,
141
+ ) {
142
+ let entries = match fs::read_dir(dir) {
143
+ Ok(e) => e,
144
+ Err(_) => return,
145
+ };
146
+ for entry in entries.flatten() {
147
+ let path = entry.path();
148
+ let name = match path.file_name().and_then(|n| n.to_str()) {
149
+ Some(n) => n,
150
+ None => continue,
151
+ };
152
+ if should_skip_path(&path, name, skip) {
153
+ continue;
154
+ }
155
+ if path.is_dir() {
156
+ // Never descend into the compiled output — its JS is bundled/minified
157
+ // and would falsely trip the analysis.
158
+ if let (Some(oc), Some(pc)) = (output_canon, path.canonicalize().ok()) {
159
+ if pc.as_path() == oc {
160
+ continue;
161
+ }
162
+ }
163
+ collect_state_sources(&path, output_canon, skip, sources, js_paths);
164
+ } else {
165
+ match path.extension().and_then(|e| e.to_str()) {
166
+ Some("js") => {
167
+ if let Ok(code) = fs::read_to_string(&path) {
168
+ sources.push(code);
169
+ js_paths.push(path);
170
+ }
171
+ }
172
+ Some("html") => {
173
+ if let Ok(html) = fs::read_to_string(&path) {
174
+ sources.extend(extract_inline_scripts(&html));
175
+ sources.extend(extract_event_handlers(&html));
176
+ }
177
+ }
178
+ _ => {}
179
+ }
180
+ }
181
+ }
182
+ }
183
+
184
+ /// Inner JS of every `<script>…</script>` block.
185
+ fn extract_inline_scripts(html: &str) -> Vec<String> {
186
+ let re = Regex::new(r"(?is)<script\b[^>]*>(.*?)</script>").unwrap();
187
+ re.captures_iter(html)
188
+ .map(|c| c[1].to_string())
189
+ .filter(|s| !s.trim().is_empty())
190
+ .collect()
191
+ }
192
+
193
+ /// Bodies of `on*="…"` / `on*='…'` event-handler attributes. The attribute-name
194
+ /// match is heuristic (it can catch a non-handler like `only="…"`), so each body
195
+ /// is kept only if it parses as JS — genuine handlers parse, false positives drop.
196
+ fn extract_event_handlers(html: &str) -> Vec<String> {
197
+ use crate::compiler::reassignment_analyzer::parses_as_js;
198
+ let re = Regex::new(r#"(?is)\son[a-z]+\s*=\s*(?:"([^"]*)"|'([^']*)')"#).unwrap();
199
+ re.captures_iter(html)
200
+ .filter_map(|c| c.get(1).or_else(|| c.get(2)).map(|m| m.as_str().to_string()))
201
+ .filter(|body| !body.trim().is_empty() && parses_as_js(body))
202
+ .collect()
203
+ }
204
+
205
+ /// Rename `src="{component_src}"` to `data-vibe-recursive-src="{component_src}"`
206
+ /// on `<component>` / `<div class="component">` tags. Used during cache build
207
+ /// to neutralize cyclic refs before they reach `inline_component_elements` —
208
+ /// the inliner's `src=` regex won't match the renamed attribute, so it stops
209
+ /// re-expanding. `process_html_with_cache` restores the attribute to plain
210
+ /// `src=` at the end of compilation so the runtime fetches it normally.
211
+ fn escape_recursive_src(content: &str, component_src: &str) -> String {
212
+ let pattern = format!(
213
+ r#"(<(?:component|div)\s+[^>]*\b)src="{}""#,
214
+ regex::escape(component_src),
215
+ );
216
+ let re = Regex::new(&pattern).unwrap();
217
+ re.replace_all(content, format!(r#"$1data-vibe-recursive-src="{}""#, component_src))
218
+ .into_owned()
219
+ }
220
+
221
+ /// Remove FOUC prevention class and/or attribute from compiled HTML
222
+ /// Since HTML is pre-rendered, there's no need for FOUC prevention
223
+ fn remove_fouc_prevention(html: String) -> String {
224
+ let mut result = html;
225
+
226
+ // Remove as class: class="vibe-fouc" or class="vibe-fouc other-class" or class="other-class vibe-fouc"
227
+ result = Regex::new(&format!(r#"\s+class="{}""#, FOUC_CLASS_OR_ATTR))
228
+ .unwrap()
229
+ .replace_all(&result, "")
230
+ .to_string();
231
+ result = Regex::new(&format!(r#"class="{}\s+"#, FOUC_CLASS_OR_ATTR))
232
+ .unwrap()
233
+ .replace_all(&result, r#"class=""#)
234
+ .to_string();
235
+ result = Regex::new(&format!(r#"class="([^"]*\s+){}(\s+[^"]*)""#, FOUC_CLASS_OR_ATTR))
236
+ .unwrap()
237
+ .replace_all(&result, r#"class="$1$2""#)
238
+ .to_string();
239
+
240
+ // Remove as attribute: vibe-fouc or vibe-fouc=""
241
+ result = Regex::new(&format!(r#"\s+{}(?:="[^"]*")?"#, FOUC_CLASS_OR_ATTR))
242
+ .unwrap()
243
+ .replace_all(&result, "")
244
+ .to_string();
245
+
246
+ result
247
+ }
248
+
249
+ #[derive(Error, Debug)]
250
+ pub enum CompileError {
251
+ #[error("Source directory not found: {0}")]
252
+ SourceNotFound(String),
253
+ #[error("Failed to create output directory: {0}")]
254
+ CreateDirError(String),
255
+ #[error("Failed to read file {path}: {source}")]
256
+ ReadError {
257
+ path: String,
258
+ #[source]
259
+ source: std::io::Error,
260
+ },
261
+ #[error("Failed to write file {path}: {source}")]
262
+ WriteError {
263
+ path: String,
264
+ #[source]
265
+ source: std::io::Error,
266
+ },
267
+ #[error("{file}:{line} - {message}")]
268
+ ValidationError {
269
+ file: String,
270
+ line: usize,
271
+ message: String,
272
+ },
273
+ #[error("Parse error: {0}")]
274
+ ParseError(#[from] crate::parser::html::ParseError),
275
+ #[error("Failed to execute command: {0}")]
276
+ CommandError(String),
277
+ #[error("Package manager not found")]
278
+ PackageManagerNotFound,
279
+ #[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.")]
280
+ ReservedComponentName {
281
+ component_name: String,
282
+ file_path: String,
283
+ },
284
+ #[error("SPA mode: {0}")]
285
+ SpaError(String),
286
+ }
287
+
288
+ pub struct CompileStats {
289
+ pub files_compiled: usize,
290
+ pub files_copied: usize,
291
+ pub internal_components_unique: usize,
292
+ pub external_components_unique: usize,
293
+ pub internal_components_total: usize,
294
+ pub external_components_total: usize,
295
+ pub compile_time_ms: f64,
296
+ pub copy_time_ms: f64,
297
+ pub components_time_ms: f64,
298
+ pub validation_time_ms: Option<f64>,
299
+ pub node_modules_time_ms: Option<f64>,
300
+ pub package_manager: Option<String>,
301
+ pub node_modules_copied_as_is: bool,
302
+ pub components_as_is: bool,
303
+ }
304
+
305
+ pub struct ManifestStats {
306
+ pub pages_processed: usize,
307
+ pub pages_skipped: usize,
308
+ pub total_time_ms: f64,
309
+ }
310
+
311
+ #[derive(Debug, Clone, PartialEq)]
312
+ enum FileOperation {
313
+ Compiled,
314
+ Copied,
315
+ }
316
+
317
+ struct VerboseLogger {
318
+ operations: BTreeMap<String, Vec<(String, FileOperation)>>, // dir -> [(filename, operation)]
319
+ component_counts: HashMap<String, usize>, // component src -> count
320
+ component_children: HashMap<String, HashSet<String>>, // component src -> direct children
321
+ components_as_is: bool,
322
+ }
323
+
324
+ impl VerboseLogger {
325
+ fn new(components_as_is: bool) -> Self {
326
+ Self {
327
+ operations: BTreeMap::new(),
328
+ component_counts: HashMap::new(),
329
+ component_children: HashMap::new(),
330
+ components_as_is,
331
+ }
332
+ }
333
+
334
+ fn log_component_occurrence(&mut self, src: String) {
335
+ // Increment count for this component
336
+ *self.component_counts.entry(src).or_insert(0) += 1;
337
+ }
338
+
339
+ fn log_component_children(&mut self, src: String, children: Vec<String>) {
340
+ // Track children relationships
341
+ if !children.is_empty() {
342
+ self.component_children
343
+ .entry(src)
344
+ .or_insert_with(HashSet::new)
345
+ .extend(children);
346
+ }
347
+ }
348
+
349
+ fn log(&mut self, path: &Path, operation: FileOperation, source_root: &Path) {
350
+ let relative = path.strip_prefix(source_root).unwrap_or(path);
351
+ let dir = relative.parent()
352
+ .map(|p| p.to_string_lossy().to_string())
353
+ .unwrap_or_else(|| String::from(""));
354
+ let filename = relative.file_name()
355
+ .map(|f| f.to_string_lossy().to_string())
356
+ .unwrap_or_default();
357
+
358
+ self.operations
359
+ .entry(dir)
360
+ .or_insert_with(Vec::new)
361
+ .push((filename, operation));
362
+ }
363
+
364
+ fn print_components(&self) {
365
+ if self.components_as_is {
366
+ println!("\n{} (0 {}, 0 {}) - \"components-as-is\": true",
367
+ "Compiled components".bright_cyan(),
368
+ "internal".bright_green(),
369
+ "external".bright_magenta()
370
+ );
371
+ return;
372
+ }
373
+
374
+ if self.component_counts.is_empty() {
375
+ return;
376
+ }
377
+
378
+ // Count internal vs external
379
+ let mut internal_total = 0;
380
+ let mut external_total = 0;
381
+ for (src, count) in &self.component_counts {
382
+ if src.starts_with("http://") || src.starts_with("https://") {
383
+ external_total += count;
384
+ } else {
385
+ internal_total += count;
386
+ }
387
+ }
388
+
389
+ println!("\n{} ({} {}, {} {})",
390
+ "Compiled components".bright_cyan().bold(),
391
+ internal_total,
392
+ "internal".bright_green(),
393
+ external_total,
394
+ "external".bright_magenta()
395
+ );
396
+ println!();
397
+
398
+ // Sort components by count (descending) then alphabetically
399
+ let mut components: Vec<_> = self.component_counts.iter().collect();
400
+ components.sort_by(|(a_src, a_count), (b_src, b_count)| {
401
+ b_count.cmp(a_count).then_with(|| a_src.cmp(b_src))
402
+ });
403
+
404
+ // Find top-level components (those not referenced as children of others)
405
+ let all_children: HashSet<String> = self.component_children
406
+ .values()
407
+ .flat_map(|children| children.iter().cloned())
408
+ .collect();
409
+
410
+ // Print each top-level component with its tree
411
+ for (src, count) in &components {
412
+ // Only show as top-level if it's not a child of another component
413
+ if !all_children.contains(*src) {
414
+ let is_external = src.starts_with("http://") || src.starts_with("https://");
415
+
416
+ let display = if is_external {
417
+ format!("{} ({})", src, count).bright_magenta()
418
+ } else {
419
+ format!("{} ({})", src, count).bright_green()
420
+ };
421
+
422
+ println!("{}", display);
423
+
424
+ // Print children if any
425
+ if let Some(children) = self.component_children.get(*src) {
426
+ let mut children_vec: Vec<_> = children.iter().collect();
427
+ children_vec.sort();
428
+
429
+ for child in children_vec {
430
+ let is_external = child.starts_with("http://") || child.starts_with("https://");
431
+ let display = if is_external {
432
+ format!(" └─ {}", child).bright_magenta()
433
+ } else {
434
+ format!(" └─ {}", child).bright_green()
435
+ };
436
+ println!("{}", display);
437
+ }
438
+ }
439
+ }
440
+ }
441
+ }
442
+
443
+ fn print_compiled(&self) {
444
+ self.print_operations("Compiled HTML", FileOperation::Compiled);
445
+ }
446
+
447
+ fn print_copied(&self) {
448
+ self.print_operations("Copied files", FileOperation::Copied);
449
+ }
450
+
451
+ fn print_operations(&self, label: &str, op_type: FileOperation) {
452
+ let matching: Vec<_> = self.operations.iter()
453
+ .map(|(dir, files)| {
454
+ let filtered: Vec<_> = files.iter()
455
+ .filter(|(_, op)| {
456
+ match (&op_type, op) {
457
+ (FileOperation::Compiled, FileOperation::Compiled) => true,
458
+ (FileOperation::Copied, FileOperation::Copied) => true,
459
+ _ => false,
460
+ }
461
+ })
462
+ .map(|(name, _)| name.clone())
463
+ .collect();
464
+ (dir.clone(), filtered)
465
+ })
466
+ .filter(|(_, files)| !files.is_empty())
467
+ .collect();
468
+
469
+ if matching.is_empty() {
470
+ return;
471
+ }
472
+
473
+ let total_count: usize = matching.iter().map(|(_, files)| files.len()).sum();
474
+ println!("\n{} ({})", label.bright_cyan().bold(), total_count);
475
+ println!();
476
+
477
+ // Root files first
478
+ if let Some((_, files)) = matching.iter().find(|(dir, _)| dir.is_empty()) {
479
+ for file in files {
480
+ println!(" {}", file);
481
+ }
482
+ }
483
+
484
+ // Then directories hierarchically
485
+ self.print_directories(&matching, "", 1);
486
+ }
487
+
488
+ fn print_directories(&self, matching: &[(String, Vec<String>)], parent: &str, depth: usize) {
489
+ // Find all directories at this level
490
+ let mut dirs_at_level: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
491
+
492
+ for (dir, _) in matching {
493
+ if dir.is_empty() {
494
+ continue;
495
+ }
496
+
497
+ let remainder = if parent.is_empty() {
498
+ if dir.starts_with(parent) {
499
+ dir.as_str()
500
+ } else {
501
+ continue;
502
+ }
503
+ } else {
504
+ if dir.starts_with(parent) && dir.len() > parent.len() {
505
+ &dir[parent.len() + 1..] // +1 for the separator
506
+ } else {
507
+ continue;
508
+ }
509
+ };
510
+
511
+ if let Some(slash_pos) = remainder.find('/') {
512
+ dirs_at_level.insert(remainder[..slash_pos].to_string());
513
+ } else if !remainder.is_empty() {
514
+ dirs_at_level.insert(remainder.to_string());
515
+ }
516
+ }
517
+
518
+ // Print each directory
519
+ for dir_name in dirs_at_level {
520
+ let full_path = if parent.is_empty() {
521
+ dir_name.clone()
522
+ } else {
523
+ format!("{}/{}", parent, dir_name)
524
+ };
525
+
526
+ let indent = " ".repeat(depth);
527
+
528
+ // Count files directly in this directory (not subdirectories)
529
+ let file_count = matching.iter()
530
+ .find(|(d, _)| d == &full_path)
531
+ .map(|(_, files)| files.len())
532
+ .unwrap_or(0);
533
+
534
+ if file_count > 0 {
535
+ println!("{}{} ({})", indent, format!("/{}", dir_name).cyan(), file_count);
536
+ } else {
537
+ println!("{}{}", indent, format!("/{}", dir_name).cyan());
538
+ }
539
+
540
+ // Print files in this directory
541
+ if let Some((_, files)) = matching.iter().find(|(d, _)| d == &full_path) {
542
+ for file in files {
543
+ println!("{} {}", indent, file);
544
+ }
545
+ }
546
+
547
+ // Recurse into subdirectories
548
+ self.print_directories(matching, &full_path, depth + 1);
549
+ }
550
+ }
551
+ }
552
+
553
+ pub struct Compiler {
554
+ config: Config,
555
+ verbose: bool,
556
+ logger: Option<VerboseLogger>,
557
+ unique_components: HashSet<String>,
558
+ /// Cache for all components (both internal paths and external URLs)
559
+ component_cache: std::collections::HashMap<String, String>,
560
+ /// Compiled (pre-stamp) HTML of every page this compiler wrote, keyed by
561
+ /// output path. Manifest generation builds from these bytes — never from a
562
+ /// disk read-back — so a concurrent writer rewriting the output directory
563
+ /// (e.g. a second compiler) can't feed a torn read into a manifest.
564
+ compiled_html: HashMap<PathBuf, String>,
565
+ }
566
+
567
+ impl Compiler {
568
+ pub fn new(config: Config, verbose: bool) -> Self {
569
+ let logger = if verbose { Some(VerboseLogger::new(config.components_as_is)) } else { None };
570
+ Self {
571
+ config,
572
+ verbose,
573
+ logger,
574
+ unique_components: HashSet::new(),
575
+ component_cache: HashMap::new(),
576
+ compiled_html: HashMap::new(),
577
+ }
578
+ }
579
+
580
+ /// Drop only the named cache entries, forcing those components to be
581
+ /// re-read + re-inlined on the next compile while every other component is
582
+ /// reused from cache. `keys` are normalized component srcs (e.g.
583
+ /// `/components/Sidebar.html`). Returns how many entries were actually
584
+ /// removed. This is the incremental-watch path: invalidate the edited
585
+ /// component and its inlining ancestors, nothing else.
586
+ pub fn invalidate_components(&mut self, keys: &std::collections::HashSet<String>) -> usize {
587
+ let before = self.component_cache.len();
588
+ self.component_cache.retain(|key, _| !keys.contains(key));
589
+ before - self.component_cache.len()
590
+ }
591
+
592
+ /// Number of components currently held in the inlining cache.
593
+ pub fn cached_component_count(&self) -> usize {
594
+ self.component_cache.len()
595
+ }
596
+
597
+ /// Take over another compiler's warm component cache, leaving it empty.
598
+ /// The watch loop uses this to carry the initial full-compile cache into the
599
+ /// incremental compiler, so even the first edit reuses everything unchanged
600
+ /// instead of re-fetching the whole tree.
601
+ pub fn adopt_component_cache(&mut self, other: &mut Compiler) {
602
+ self.component_cache = std::mem::take(&mut other.component_cache);
603
+ }
604
+
605
+ pub fn compile(&mut self) -> Result<CompileStats, CompileError> {
606
+ let mut stats = CompileStats {
607
+ files_compiled: 0,
608
+ files_copied: 0,
609
+ internal_components_unique: 0,
610
+ external_components_unique: 0,
611
+ internal_components_total: 0,
612
+ external_components_total: 0,
613
+ compile_time_ms: 0.0,
614
+ copy_time_ms: 0.0,
615
+ components_time_ms: 0.0,
616
+ validation_time_ms: None,
617
+ node_modules_time_ms: None,
618
+ package_manager: None,
619
+ node_modules_copied_as_is: false,
620
+ components_as_is: self.config.components_as_is,
621
+ };
622
+
623
+ // Validate source exists
624
+ if !self.config.source.exists() {
625
+ return Err(CompileError::SourceNotFound(
626
+ self.config.source.display().to_string(),
627
+ ));
628
+ }
629
+
630
+ // Load components for HTML compilation
631
+ let mut parser = HtmlParser::new(self.config.components_path());
632
+ parser.load_elements()?;
633
+
634
+ // Validate component names don't conflict with reserved elements
635
+ self.validate_component_names()?;
636
+
637
+ // Fetch and cache all components for inlining (when not components_as_is)
638
+ // This is separate from validation - we need component content for inlining regardless of validate flag
639
+ if !self.config.components_as_is {
640
+ let fetch_start = Instant::now();
641
+ if self.verbose {
642
+ println!("\nFetching components for inlining...");
643
+ }
644
+ self.fetch_all_components(&parser)?;
645
+ stats.components_time_ms = fetch_start.elapsed().as_secs_f64() * 1000.0;
646
+ if self.verbose {
647
+ println!(" All components fetched successfully");
648
+ }
649
+ }
650
+
651
+ // Validate HTML syntax (always runs)
652
+ let validation_start = Instant::now();
653
+ if self.verbose {
654
+ println!("\nValidating HTML syntax...");
655
+ }
656
+ self.validate_html_syntax()?;
657
+ stats.validation_time_ms = Some(validation_start.elapsed().as_secs_f64() * 1000.0);
658
+ if self.verbose {
659
+ println!(" HTML validation completed successfully");
660
+ }
661
+
662
+ // Clean output directory if not in no-clean mode
663
+ if !self.config.no_clean && self.config.output.exists() {
664
+ if self.verbose {
665
+ println!(" Cleaning output directory: {}", self.config.output.display());
666
+ }
667
+ fs::remove_dir_all(&self.config.output).map_err(|e| {
668
+ CompileError::WriteError {
669
+ path: self.config.output.display().to_string(),
670
+ source: e,
671
+ }
672
+ })?;
673
+ }
674
+
675
+ // Create output directory
676
+ if !self.config.output.exists() {
677
+ fs::create_dir_all(&self.config.output).map_err(|_| {
678
+ CompileError::CreateDirError(self.config.output.display().to_string())
679
+ })?;
680
+ if self.verbose {
681
+ println!(" Created output directory: {}", self.config.output.display());
682
+ }
683
+ }
684
+
685
+ // Canonicalize both output and source paths for reliable comparison
686
+ let canonical_output = self.config.output.canonicalize()
687
+ .unwrap_or_else(|_| self.config.output.clone());
688
+ let canonical_source = self.config.source.canonicalize()
689
+ .unwrap_or_else(|_| self.config.source.clone());
690
+
691
+ // Track compile and copy times separately
692
+ let compile_start = Instant::now();
693
+
694
+ // Process all HTML files in source (excluding components directory)
695
+ self.process_directory_html_only(&canonical_source, &parser, "", &canonical_output, &canonical_source, &mut stats)?;
696
+
697
+ // Process source directory for assets (CSS, JS, images, etc.)
698
+ self.process_directory_assets_only(&canonical_source, "", &canonical_output, &canonical_source, &mut stats)?;
699
+
700
+ // SPA mode: pages tree → fragments + route table + composed shell.
701
+ // Runs after both passes so the shell wins over any root index.html
702
+ // and fragments land beside the mirrored components.
703
+ if self.config.spa {
704
+ stats.files_compiled += self.compile_spa(&parser)?;
705
+ }
706
+
707
+ let process_time = compile_start.elapsed();
708
+
709
+ // Print verbose output after processing (components first, then HTML, then copied)
710
+ if let Some(ref logger) = self.logger {
711
+ logger.print_components();
712
+ logger.print_compiled();
713
+ logger.print_copied();
714
+ }
715
+
716
+ // Calculate unique components after processing all files
717
+ let internal_unique = self.unique_components.iter()
718
+ .filter(|src| !src.starts_with("http://") && !src.starts_with("https://"))
719
+ .count();
720
+ let external_unique = self.unique_components.iter()
721
+ .filter(|src| src.starts_with("http://") || src.starts_with("https://"))
722
+ .count();
723
+
724
+ stats.internal_components_unique = internal_unique;
725
+ stats.external_components_unique = external_unique;
726
+
727
+ // Calculate times based on counts (rough estimate)
728
+ // Component operations are part of compile time but tracked separately for user visibility
729
+ let total_files = stats.files_compiled + stats.files_copied;
730
+ let total_components = stats.internal_components_total + stats.external_components_total;
731
+
732
+ if total_files > 0 {
733
+ let total_ms = process_time.as_secs_f64() * 1000.0;
734
+
735
+ if total_components > 0 {
736
+ // Component processing is slowest, compile is medium, copy is fastest
737
+ // Rough weights: component=3, compile=2, copy=1
738
+ let total_weight = (total_components * 3 + stats.files_compiled * 2 + stats.files_copied) as f64;
739
+ stats.components_time_ms = (total_ms * (total_components * 3) as f64) / total_weight;
740
+ stats.compile_time_ms = (total_ms * (stats.files_compiled * 2) as f64) / total_weight;
741
+ stats.copy_time_ms = (total_ms * stats.files_copied as f64) / total_weight;
742
+ } else {
743
+ // No component operations, just split between compile and copy
744
+ stats.compile_time_ms = (total_ms * stats.files_compiled as f64) / total_files as f64;
745
+ stats.copy_time_ms = (total_ms * stats.files_copied as f64) / total_files as f64;
746
+ }
747
+ }
748
+
749
+ // Handle node_modules after successful compilation
750
+ if self.config.working_dir.join("node_modules").exists() {
751
+ if self.config.node_modules_as_is {
752
+ // Copy node_modules as-is (no install, just copy)
753
+ let nm_start = Instant::now();
754
+ self.copy_node_modules_as_is()?;
755
+ stats.node_modules_time_ms = Some(nm_start.elapsed().as_secs_f64() * 1000.0);
756
+ stats.node_modules_copied_as_is = true;
757
+
758
+ if self.verbose {
759
+ println!("\n Copied node_modules as-is");
760
+ }
761
+ } else {
762
+ // Use production-only strategy (runs install commands)
763
+ let pkg_manager = detect_package_manager(&self.config.working_dir).ok();
764
+ stats.package_manager = pkg_manager.clone();
765
+
766
+ let nm_start = Instant::now();
767
+ self.copy_production_node_modules()?;
768
+ stats.node_modules_time_ms = Some(nm_start.elapsed().as_secs_f64() * 1000.0);
769
+
770
+ if self.verbose {
771
+ println!(" Copied production dependencies");
772
+ }
773
+ }
774
+ }
775
+
776
+ Ok(stats)
777
+ }
778
+
779
+ /// Compile specific HTML files (incremental compilation for watch mode)
780
+ pub fn compile_specific_html_files(
781
+ &mut self,
782
+ files: &[PathBuf],
783
+ parser: &HtmlParser,
784
+ ) -> Result<CompileStats, CompileError> {
785
+ let mut stats = CompileStats {
786
+ files_compiled: 0,
787
+ files_copied: 0,
788
+ internal_components_unique: 0,
789
+ external_components_unique: 0,
790
+ internal_components_total: 0,
791
+ external_components_total: 0,
792
+ compile_time_ms: 0.0,
793
+ copy_time_ms: 0.0,
794
+ components_time_ms: 0.0,
795
+ validation_time_ms: None,
796
+ node_modules_time_ms: None,
797
+ package_manager: None,
798
+ node_modules_copied_as_is: false,
799
+ components_as_is: self.config.components_as_is,
800
+ };
801
+
802
+ let start = Instant::now();
803
+
804
+ // Pre-fetch components needed by these specific files (only if not components_as_is)
805
+ if !self.config.components_as_is {
806
+ let fetch_start = Instant::now();
807
+ self.fetch_components_for_files(files, parser)?;
808
+ stats.components_time_ms = fetch_start.elapsed().as_secs_f64() * 1000.0;
809
+ }
810
+
811
+ for file_path in files {
812
+ // Validate HTML before compiling (same as full compile path)
813
+ let content = fs::read_to_string(file_path).map_err(|e| CompileError::ReadError {
814
+ path: file_path.display().to_string(),
815
+ source: e,
816
+ })?;
817
+ self.validate_html(&content, file_path)?;
818
+
819
+ // Calculate relative path
820
+ let relative_path = file_path
821
+ .strip_prefix(&self.config.source)
822
+ .map(|p| p.parent().unwrap_or(Path::new("")))
823
+ .unwrap_or(Path::new(""))
824
+ .to_str()
825
+ .unwrap_or("");
826
+
827
+ let (internal, external, component_srcs) = self.compile_html_file(file_path, parser, relative_path)?;
828
+ stats.files_compiled += 1;
829
+ stats.internal_components_total += internal;
830
+ stats.external_components_total += external;
831
+
832
+ // Track unique components
833
+ for src in &component_srcs {
834
+ self.unique_components.insert(src.clone());
835
+ }
836
+
837
+ if let Some(ref mut logger) = self.logger {
838
+ logger.log(file_path, FileOperation::Compiled, &self.config.source);
839
+ for src in component_srcs {
840
+ logger.log_component_occurrence(src);
841
+ }
842
+ }
843
+ }
844
+
845
+ // Calculate unique components
846
+ let internal_unique = self.unique_components.iter()
847
+ .filter(|src| !src.starts_with("http://") && !src.starts_with("https://"))
848
+ .count();
849
+ let external_unique = self.unique_components.iter()
850
+ .filter(|src| src.starts_with("http://") || src.starts_with("https://"))
851
+ .count();
852
+
853
+ stats.internal_components_unique = internal_unique;
854
+ stats.external_components_unique = external_unique;
855
+
856
+ // Calculate compile time (excluding component fetch time which is already tracked)
857
+ let elapsed = start.elapsed().as_secs_f64() * 1000.0;
858
+ stats.compile_time_ms = elapsed - stats.components_time_ms;
859
+
860
+ Ok(stats)
861
+ }
862
+
863
+ /// Copy specific asset files (incremental compilation for watch mode)
864
+ pub fn copy_specific_asset_files(
865
+ &mut self,
866
+ files: &[PathBuf],
867
+ ) -> Result<CompileStats, CompileError> {
868
+ let mut stats = CompileStats {
869
+ files_compiled: 0,
870
+ files_copied: 0,
871
+ internal_components_unique: 0,
872
+ external_components_unique: 0,
873
+ internal_components_total: 0,
874
+ external_components_total: 0,
875
+ compile_time_ms: 0.0,
876
+ copy_time_ms: 0.0,
877
+ components_time_ms: 0.0,
878
+ validation_time_ms: None,
879
+ node_modules_time_ms: None,
880
+ package_manager: None,
881
+ node_modules_copied_as_is: false,
882
+ components_as_is: self.config.components_as_is,
883
+ };
884
+
885
+ let start = Instant::now();
886
+
887
+ for file_path in files {
888
+ // Calculate relative path for preserving directory structure
889
+ let relative_path = file_path
890
+ .strip_prefix(&self.config.source)
891
+ .map(|p| p.parent().unwrap_or(Path::new("")))
892
+ .unwrap_or(Path::new(""))
893
+ .to_str()
894
+ .unwrap_or("");
895
+
896
+ self.copy_file(file_path, relative_path)?;
897
+ stats.files_copied += 1;
898
+
899
+ if let Some(ref mut logger) = self.logger {
900
+ logger.log(file_path, FileOperation::Copied, &self.config.source);
901
+ }
902
+ }
903
+
904
+ let elapsed = start.elapsed().as_secs_f64() * 1000.0;
905
+ stats.copy_time_ms = elapsed;
906
+
907
+ Ok(stats)
908
+ }
909
+
910
+ /// Generate manifests for specific HTML files (incremental compilation for watch mode)
911
+ pub fn generate_manifests_for_files(&self, files: &[PathBuf]) -> Result<ManifestStats, CompileError> {
912
+ let start = Instant::now();
913
+
914
+ // Mirror generate_manifests: process each page's manifest in parallel.
915
+ // Manifest generation is the bulk of an incremental recompile (static
916
+ // analysis + serialization per page), so a sequential loop over the
917
+ // affected pages left most cores idle and dominated watch latency.
918
+ let output_dir = self.config.output.clone();
919
+ let source_root = self.config.source.clone();
920
+ let verbose = self.verbose;
921
+ let iterations_as_is = self.config.iterations_as_is;
922
+ let components_as_is = self.config.components_as_is;
923
+ let manifest_root = self.config.root.clone();
924
+ let spa = self.config.spa;
925
+ // Resolve constant global-state keys once, shared read-only across pages.
926
+ let global_constants = self.compute_global_constants();
927
+
928
+ let compiled_html = &self.compiled_html;
929
+ let results: Vec<bool> = files
930
+ .par_iter()
931
+ .map(|file_path| {
932
+ let relative_path = match file_path.strip_prefix(&source_root).unwrap_or(file_path).to_str() {
933
+ Some(r) => r,
934
+ None => return false,
935
+ };
936
+
937
+ let output_path = output_dir.join(relative_path);
938
+ if !output_path.exists() {
939
+ return false;
940
+ }
941
+
942
+ // The HTML this compiler produced in memory is the source of
943
+ // truth; the on-disk file may have been rewritten by another
944
+ // process since we wrote it. Disk is only a fallback for pages
945
+ // this compiler never compiled (e.g. no-clean leftovers).
946
+ let disk_html;
947
+ let html: &str = match compiled_html.get(&output_path) {
948
+ Some(h) => h,
949
+ None => {
950
+ disk_html = match fs::read_to_string(&output_path) {
951
+ Ok(h) => h,
952
+ Err(_) => return false,
953
+ };
954
+ &disk_html
955
+ }
956
+ };
957
+
958
+ match Self::generate_file_manifest(
959
+ html,
960
+ &output_path,
961
+ &output_dir,
962
+ relative_path,
963
+ verbose,
964
+ iterations_as_is,
965
+ components_as_is,
966
+ &source_root,
967
+ manifest_root.as_deref(),
968
+ &global_constants,
969
+ !(spa && relative_path == "index.html"),
970
+ ) {
971
+ Ok(()) => true,
972
+ Err(e) => {
973
+ if verbose {
974
+ eprintln!(" Skipped ({}): {}", e, relative_path);
975
+ }
976
+ false
977
+ }
978
+ }
979
+ })
980
+ .collect();
981
+
982
+ let pages_processed = results.iter().filter(|&&ok| ok).count();
983
+ let pages_skipped = results.len() - pages_processed;
984
+
985
+ let total_time_ms = start.elapsed().as_millis() as f64;
986
+
987
+ Ok(ManifestStats {
988
+ pages_processed,
989
+ pages_skipped,
990
+ total_time_ms,
991
+ })
992
+ }
993
+
994
+ /// Generate manifest for a single file
995
+ fn generate_file_manifest(
996
+ html: &str,
997
+ html_path: &Path,
998
+ output_dir: &Path,
999
+ relative_path: &str,
1000
+ _verbose: bool,
1001
+ iterations_as_is: bool,
1002
+ components_as_is: bool,
1003
+ source_root: &Path,
1004
+ manifest_root: Option<&str>,
1005
+ global_constants: &Map<String, Value>,
1006
+ stamp: bool,
1007
+ ) -> Result<(), String> {
1008
+ use crate::compiler::manifest_builder::ManifestBuilder;
1009
+ use crate::compiler::component_tagger::ComponentTagger;
1010
+ use crate::compiler::value_stamper::ValueStamper;
1011
+
1012
+ // Relocate whitespace-bearing name-bindings into a value-attribute BEFORE any
1013
+ // html5ever round-trip splits them on their spaces (see name_binding_protect).
1014
+ let protected = crate::compiler::name_binding_protect::protect(html);
1015
+
1016
+ // Tag components with deterministic IDs and structure state
1017
+ let tagged = ComponentTagger::tag_components(&protected, &source_root.to_path_buf())?;
1018
+ let html = &tagged.html; // Use modified HTML with data-vibe-component-id attributes
1019
+ let state = tagged.state;
1020
+
1021
+ // Build manifest from pre-stamp HTML (both conditional branches present, @[...] markers intact)
1022
+ // This ensures restoration.template has both branches so runtime can switch between them
1023
+ let manifest_builder = ManifestBuilder::new();
1024
+ let manifest = manifest_builder.build_from_html(html, &state, iterations_as_is)?;
1025
+
1026
+ // Map the output-relative path to the served-URL path: drop the `root`
1027
+ // dir (the folder served at /, e.g. `pages`) and collapse dynamic
1028
+ // `$param` segments to a single `$` token. This lets the runtime resolve
1029
+ // a manifest from a clean URL — `/armory` and `/the-arena/123` find
1030
+ // `armory.html.manifest.js` and `the-arena/$.html.manifest.js`.
1031
+ let manifest_rel = manifest_url_path(relative_path, manifest_root);
1032
+
1033
+ // Write manifest
1034
+ let manifest_path = output_dir
1035
+ .join("vibe-hyperspeed")
1036
+ .join(format!("{}.manifest.js", manifest_rel));
1037
+
1038
+ // Ensure directory exists
1039
+ if let Some(parent) = manifest_path.parent() {
1040
+ fs::create_dir_all(parent)
1041
+ .map_err(|e| format!("Failed to create manifest directory: {}", e))?;
1042
+ }
1043
+
1044
+ let manifest_json = serde_json::to_string(&manifest)
1045
+ .map_err(|e| format!("Failed to serialize manifest: {}", e))?;
1046
+
1047
+ let manifest_js = format!(
1048
+ "// Pre-compiled manifest for /{}\n// Generated by Vibe compiler\n\nexport default {};\n",
1049
+ manifest_rel,
1050
+ manifest_json
1051
+ );
1052
+
1053
+ atomic_write(&manifest_path, &manifest_js)
1054
+ .map_err(|e| format!("Failed to write manifest: {}", e))?;
1055
+
1056
+ // Stamp the compiled HTML with initial state values for FOUC prevention:
1057
+ // - @[...] bindings replaced with their initial values
1058
+ // - Only the active conditional branch is kept (inactive branch stripped)
1059
+ // - Iterations are pre-rendered with initial array data
1060
+ // The manifest (written above) preserves BOTH branches so the runtime can
1061
+ // restore them and switch between branches reactively.
1062
+ //
1063
+ // The SPA shell opts out (stamp: false): its state is location-dependent
1064
+ // — unstampable by design — and it is served at EVERY route path while
1065
+ // its manifest only resolves at /. Stamping would strip the raw
1066
+ // `@[page.src]` outlet binding and leave deep links with nothing to
1067
+ // hydrate from; unstamped, the runtime processes the raw binding at any
1068
+ // URL (vibe-fouc covers the flash).
1069
+ if stamp {
1070
+ let stamper = ValueStamper::with_constants(&state, components_as_is, global_constants)
1071
+ .map_err(|e| format!("Failed to create value stamper: {}", e))?;
1072
+ let stamped = stamper.stamp_html(html.to_string())
1073
+ .map_err(|e| format!("Failed to stamp HTML: {}", e))?;
1074
+
1075
+ atomic_write(html_path, &stamped)
1076
+ .map_err(|e| format!("Failed to write stamped HTML: {}", e))?;
1077
+ }
1078
+
1079
+ Ok(())
1080
+ }
1081
+
1082
+ /// Resolve the global `$` state keys that reassignment analysis proves are
1083
+ /// compile-time constants, mapped to their resolved values — the only globals
1084
+ /// safe to value-stamp into pre-rendered HTML. Scans every JS source under the
1085
+ /// project source root (`.js` modules, inline `<script>` bodies, and `on*`
1086
+ /// handler bodies), skipping the compiled output and the configured skips.
1087
+ /// Returns an empty map on any doubt (see reassignment_analyzer soundness).
1088
+ fn compute_global_constants(&self) -> Map<String, Value> {
1089
+ use crate::compiler::js_analyzer::JsAnalyzer;
1090
+ use crate::compiler::reassignment_analyzer::classify_constant_keys;
1091
+
1092
+ let root = &self.config.source;
1093
+ let output_canon = self.config.output.canonicalize().ok();
1094
+
1095
+ let mut sources: Vec<String> = Vec::new();
1096
+ let mut js_paths: Vec<PathBuf> = Vec::new();
1097
+ collect_state_sources(
1098
+ root,
1099
+ output_canon.as_deref(),
1100
+ &self.config.skip_files,
1101
+ &mut sources,
1102
+ &mut js_paths,
1103
+ );
1104
+
1105
+ // Resolve the global vibe()/state() initial state. Partial resolution
1106
+ // keeps statically-resolvable keys (e.g. `version: VERSION`) and drops
1107
+ // runtime-only ones (`settings: loadLocalStorage(...)`), which is exactly
1108
+ // the set we could ever stamp.
1109
+ let mut global_state: Map<String, Value> = Map::new();
1110
+ for path in &js_paths {
1111
+ let code = match fs::read_to_string(path) {
1112
+ Ok(c) => c,
1113
+ Err(_) => continue,
1114
+ };
1115
+ if !(code.contains("vibe(") || code.contains("state(")) {
1116
+ continue;
1117
+ }
1118
+ let mut analyzer = JsAnalyzer::new(root.clone());
1119
+ if let Some(Value::Object(state)) = analyzer.extract_state(&code) {
1120
+ for (key, value) in state {
1121
+ global_state.insert(key, value);
1122
+ }
1123
+ }
1124
+ }
1125
+ if global_state.is_empty() {
1126
+ return Map::new();
1127
+ }
1128
+
1129
+ let constant_keys = classify_constant_keys(&global_state, &sources);
1130
+ global_state
1131
+ .into_iter()
1132
+ .filter(|(key, _)| constant_keys.contains(key))
1133
+ .collect()
1134
+ }
1135
+
1136
+ /// Generate manifests (called separately from main.rs if needed)
1137
+ pub fn generate_manifests(&self) -> Result<ManifestStats, CompileError> {
1138
+
1139
+ let start = Instant::now();
1140
+ let mut pages_processed = 0;
1141
+ let mut pages_skipped = 0;
1142
+
1143
+ if self.verbose {
1144
+ println!("\nGenerating manifests (static analysis)");
1145
+ }
1146
+
1147
+ // Find all HTML files in compiled output (excluding components directory)
1148
+ let html_files = self.find_all_html_files(&self.config.output)?;
1149
+
1150
+ // Process manifests in parallel
1151
+ let output_dir = self.config.output.clone();
1152
+ let source_root = self.config.source.clone();
1153
+ let verbose = self.verbose;
1154
+ let iterations_as_is = self.config.iterations_as_is;
1155
+ let components_as_is = self.config.components_as_is;
1156
+ let manifest_root = self.config.root.clone();
1157
+ let spa = self.config.spa;
1158
+ // Resolve which global state keys are constant once, then share across all
1159
+ // pages (read-only; `&Map` is Sync so the parallel map can borrow it).
1160
+ let global_constants = self.compute_global_constants();
1161
+
1162
+ let compiled_html = &self.compiled_html;
1163
+ let results: Vec<_> = html_files
1164
+ .par_iter()
1165
+ .map(|html_path| {
1166
+ let relative_path = html_path.strip_prefix(&output_dir)
1167
+ .unwrap()
1168
+ .to_str()
1169
+ .unwrap();
1170
+
1171
+ // Prefer the HTML this compiler produced in memory — the disk
1172
+ // copy may have been rewritten by another process since. Disk
1173
+ // is only a fallback for pages this compiler never compiled
1174
+ // (e.g. no-clean leftovers from an earlier run).
1175
+ let disk_html;
1176
+ let html: &str = match compiled_html.get(html_path.as_path()) {
1177
+ Some(h) => h,
1178
+ None => {
1179
+ disk_html = match fs::read_to_string(html_path) {
1180
+ Ok(h) => h,
1181
+ Err(_) => return (false, Some(relative_path.to_string())),
1182
+ };
1183
+ &disk_html
1184
+ }
1185
+ };
1186
+
1187
+ // Try to generate manifest for this file (skip on error).
1188
+ // The SPA shell keeps its raw bindings (manifest yes, stamp no).
1189
+ let stamp = !(spa && relative_path == "index.html");
1190
+ match Self::generate_file_manifest(html, html_path, &output_dir, relative_path, verbose, iterations_as_is, components_as_is, &source_root, manifest_root.as_deref(), &global_constants, stamp) {
1191
+ Ok(()) => (true, None),
1192
+ Err(e) => {
1193
+ if verbose {
1194
+ eprintln!(" Skipped ({}): {}", e, relative_path);
1195
+ }
1196
+ (false, Some(relative_path.to_string()))
1197
+ }
1198
+ }
1199
+ })
1200
+ .collect();
1201
+
1202
+ // Count results
1203
+ for (success, _) in results {
1204
+ if success {
1205
+ pages_processed += 1;
1206
+ } else {
1207
+ pages_skipped += 1;
1208
+ }
1209
+ }
1210
+
1211
+ let total_time_ms = start.elapsed().as_millis() as f64;
1212
+
1213
+ Ok(ManifestStats {
1214
+ pages_processed,
1215
+ pages_skipped,
1216
+ total_time_ms,
1217
+ })
1218
+ }
1219
+
1220
+ /// Process directory for HTML files only (used for pages directory)
1221
+ fn process_directory_html_only(
1222
+ &mut self,
1223
+ dir: &Path,
1224
+ parser: &HtmlParser,
1225
+ relative_path: &str,
1226
+ canonical_output: &Path,
1227
+ canonical_source: &Path,
1228
+ stats: &mut CompileStats,
1229
+ ) -> Result<(), CompileError> {
1230
+ let entries = fs::read_dir(dir).map_err(|e| CompileError::ReadError {
1231
+ path: dir.display().to_string(),
1232
+ source: e,
1233
+ })?;
1234
+
1235
+ for entry in entries.flatten() {
1236
+ let path = entry.path();
1237
+ let file_name = path.file_name().unwrap().to_str().unwrap();
1238
+
1239
+ if path.is_dir() {
1240
+ // Skip output directory (dynamically check, not hardcoded "compiled")
1241
+ let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone());
1242
+ if canonical_path == *canonical_output || canonical_path.starts_with(canonical_output) {
1243
+ continue;
1244
+ }
1245
+
1246
+ // Skip special directories
1247
+ if should_skip_path(&path, file_name, &self.config.skip_files) {
1248
+ continue;
1249
+ }
1250
+
1251
+ // Skip components directory
1252
+ if file_name == self.config.components {
1253
+ continue;
1254
+ }
1255
+
1256
+ // SPA mode: the pages tree becomes fragments + shell via
1257
+ // compile_spa — no mirrored MPA documents in the output.
1258
+ if self.config.spa && self.is_spa_pages_dir(&canonical_path) {
1259
+ continue;
1260
+ }
1261
+
1262
+ // Recurse into subdirectory
1263
+ let new_relative = if relative_path.is_empty() {
1264
+ file_name.to_string()
1265
+ } else {
1266
+ format!("{}/{}", relative_path, file_name)
1267
+ };
1268
+
1269
+ self.process_directory_html_only(&path, parser, &new_relative, canonical_output, canonical_source, stats)?;
1270
+ } else if path.extension().and_then(|e| e.to_str()) == Some("html") {
1271
+ let (internal, external, component_srcs) = self.compile_html_file(&path, parser, relative_path)?;
1272
+ stats.files_compiled += 1;
1273
+ stats.internal_components_total += internal;
1274
+ stats.external_components_total += external;
1275
+
1276
+ // Track unique components
1277
+ for src in &component_srcs {
1278
+ self.unique_components.insert(src.clone());
1279
+ }
1280
+
1281
+ // Build component relationships and count occurrences before borrowing logger
1282
+ let (component_relationships, all_srcs) = if self.logger.is_some() && !component_srcs.is_empty() {
1283
+ let relationships = self.build_component_relationships(&component_srcs);
1284
+ (Some(relationships), Some(component_srcs))
1285
+ } else {
1286
+ (None, None)
1287
+ };
1288
+
1289
+ if let Some(ref mut logger) = self.logger {
1290
+ logger.log(&path, FileOperation::Compiled, canonical_source);
1291
+
1292
+ // Log each component occurrence (for counting)
1293
+ if let Some(all_srcs) = all_srcs {
1294
+ for src in all_srcs {
1295
+ logger.log_component_occurrence(src);
1296
+ }
1297
+ }
1298
+
1299
+ // Log unique relationships (for tree structure)
1300
+ if let Some(relationships) = component_relationships {
1301
+ for (src, children) in relationships {
1302
+ logger.log_component_children(src, children);
1303
+ }
1304
+ }
1305
+ }
1306
+ }
1307
+ }
1308
+
1309
+ Ok(())
1310
+ }
1311
+
1312
+ /// Process directory for assets only (CSS, JS, images, etc.) - skip HTML
1313
+ fn process_directory_assets_only(
1314
+ &mut self,
1315
+ dir: &Path,
1316
+ relative_path: &str,
1317
+ canonical_output: &Path,
1318
+ canonical_source: &Path,
1319
+ stats: &mut CompileStats,
1320
+ ) -> Result<(), CompileError> {
1321
+ let entries = fs::read_dir(dir).map_err(|e| CompileError::ReadError {
1322
+ path: dir.display().to_string(),
1323
+ source: e,
1324
+ })?;
1325
+
1326
+ for entry in entries.flatten() {
1327
+ let path = entry.path();
1328
+ let file_name = path.file_name().unwrap().to_str().unwrap();
1329
+
1330
+ if path.is_dir() {
1331
+ // Skip output directory (dynamically check, not hardcoded "compiled")
1332
+ let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone());
1333
+ if canonical_path == *canonical_output || canonical_path.starts_with(canonical_output) {
1334
+ continue;
1335
+ }
1336
+
1337
+ // Skip special directories
1338
+ if should_skip_path(&path, file_name, &self.config.skip_files) {
1339
+ continue;
1340
+ }
1341
+
1342
+ // Skip node_modules (handled separately)
1343
+ if file_name == "node_modules" {
1344
+ continue;
1345
+ }
1346
+
1347
+ // SPA mode: the pages tree compiles to fragments, not output
1348
+ // pages — the HTML walker already skips it, and the assets
1349
+ // walker must too, or a co-located image/CSS/JSON resurrects
1350
+ // an `output/pages/` directory SPA output promises not to have.
1351
+ if self.config.spa && self.is_spa_pages_dir(&canonical_path) {
1352
+ continue;
1353
+ }
1354
+
1355
+ // The components directory is always mirrored to the output. With
1356
+ // components_as_is every component is loaded at runtime; otherwise
1357
+ // most are inlined at build time, but iter-prop each-root components
1358
+ // are deliberately left as runtime `<component src>` tags (see
1359
+ // is_iter_prop_root in parser/html.rs) and the runtime fetches their
1360
+ // source from here — exactly as it does in non-compiled mode.
1361
+ if file_name == self.config.components {
1362
+ let new_relative = if relative_path.is_empty() {
1363
+ file_name.to_string()
1364
+ } else {
1365
+ format!("{}/{}", relative_path, file_name)
1366
+ };
1367
+ self.copy_directory(&path, &new_relative, canonical_source, stats)?;
1368
+ // Skip further processing (don't recurse into components)
1369
+ continue;
1370
+ }
1371
+
1372
+ // Recurse into subdirectory
1373
+ let new_relative = if relative_path.is_empty() {
1374
+ file_name.to_string()
1375
+ } else {
1376
+ format!("{}/{}", relative_path, file_name)
1377
+ };
1378
+
1379
+ self.process_directory_assets_only(&path, &new_relative, canonical_output, canonical_source, stats)?;
1380
+ } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
1381
+ // Skip files matching skip patterns
1382
+ if should_skip_path(&path, file_name, &self.config.skip_files) {
1383
+ continue;
1384
+ }
1385
+
1386
+ // Only copy non-HTML assets
1387
+ if ext != "html" && MIRROR_EXTENSIONS.contains(&ext) {
1388
+ self.copy_file(&path, relative_path)?;
1389
+ stats.files_copied += 1;
1390
+ if let Some(ref mut logger) = self.logger {
1391
+ logger.log(&path, FileOperation::Copied, canonical_source);
1392
+ }
1393
+ }
1394
+ }
1395
+ }
1396
+
1397
+ Ok(())
1398
+ }
1399
+
1400
+ fn compile_html_file(
1401
+ &mut self,
1402
+ path: &Path,
1403
+ parser: &HtmlParser,
1404
+ relative_path: &str,
1405
+ ) -> Result<(usize, usize, Vec<String>), CompileError> {
1406
+ let content = fs::read_to_string(path).map_err(|e| CompileError::ReadError {
1407
+ path: path.display().to_string(),
1408
+ source: e,
1409
+ })?;
1410
+
1411
+ // Count <component src="..."> instances before processing (only if not components_as_is)
1412
+ let (internal_count, external_count, component_srcs) = if !self.config.components_as_is {
1413
+ self.extract_components(&content)
1414
+ } else {
1415
+ (0, 0, Vec::new())
1416
+ };
1417
+
1418
+ // Compile: transform custom tags to <component>, inline if needed, transform custom elements
1419
+ let processed = parser.process_html_with_cache(
1420
+ &content,
1421
+ self.config.elements_as_is,
1422
+ &self.config.reserved_elements,
1423
+ self.config.components_as_is,
1424
+ &self.config.components,
1425
+ &self.component_cache,
1426
+ );
1427
+
1428
+ // Minify if requested
1429
+ let mut output = if self.config.minify {
1430
+ minify_html(&processed)
1431
+ } else {
1432
+ processed
1433
+ };
1434
+
1435
+ // Remove FOUC prevention unless fouc_as_is is enabled
1436
+ if !self.config.fouc_as_is {
1437
+ output = remove_fouc_prevention(output);
1438
+ }
1439
+
1440
+ // Write to output
1441
+ let output_path = self.get_output_path(path, relative_path)?;
1442
+ atomic_write(&output_path, &output).map_err(|e| CompileError::WriteError {
1443
+ path: output_path.display().to_string(),
1444
+ source: e,
1445
+ })?;
1446
+
1447
+ // Keep the exact bytes for this pass's manifest generation.
1448
+ self.compiled_html.insert(output_path, output);
1449
+
1450
+ // Return component counts and src list
1451
+ Ok((internal_count, external_count, component_srcs))
1452
+ }
1453
+
1454
+ /// True when a canonicalized directory is the configured pages tree.
1455
+ fn is_spa_pages_dir(&self, canonical_path: &Path) -> bool {
1456
+ self.config
1457
+ .source
1458
+ .join(&self.config.pages)
1459
+ .canonicalize()
1460
+ .map(|pages| pages == *canonical_path)
1461
+ .unwrap_or(false)
1462
+ }
1463
+
1464
+ fn collect_page_files(dir: &Path, skip: &[String], files: &mut Vec<PathBuf>) {
1465
+ let Ok(entries) = fs::read_dir(dir) else { return };
1466
+ for entry in entries.flatten() {
1467
+ let path = entry.path();
1468
+ let name = entry.file_name();
1469
+ let name = name.to_string_lossy();
1470
+ if should_skip_path(&path, &name, skip) {
1471
+ continue;
1472
+ }
1473
+ if path.is_dir() {
1474
+ Self::collect_page_files(&path, skip, files);
1475
+ } else if path.extension().and_then(|e| e.to_str()) == Some("html") {
1476
+ files.push(path);
1477
+ }
1478
+ }
1479
+ }
1480
+
1481
+ /// SPA mode (fetched): transform every page into a fragment under
1482
+ /// output/components/vibe-spa/, then compose the shell at output root.
1483
+ /// Per-page stamped HTML, per-page manifests, and the pages/ output dir
1484
+ /// do not exist in SPA mode — fragments are runtime-parsed on mount; the
1485
+ /// shell flows through the normal manifest pipeline via compiled_html.
1486
+ /// Returns the number of files written (pages + shell) for stats.
1487
+ ///
1488
+ /// Watch mode re-runs this whole pass on any pages-tree change — the
1489
+ /// shell depends on every page's head/title/body attrs and the pass is
1490
+ /// a few milliseconds, so per-page incrementality would buy nothing.
1491
+ pub(crate) fn compile_spa(&mut self, parser: &HtmlParser) -> Result<usize, CompileError> {
1492
+ use crate::compiler::spa;
1493
+
1494
+ let pages_dir = self.config.source.join(&self.config.pages);
1495
+ if !pages_dir.exists() {
1496
+ return Err(CompileError::SpaError(format!(
1497
+ "needs a pages directory at {}",
1498
+ pages_dir.display()
1499
+ )));
1500
+ }
1501
+ let reserved = self.config.components_path().join("vibe-spa");
1502
+ if reserved.exists() {
1503
+ return Err(CompileError::SpaError(format!(
1504
+ "{} is reserved for compiled page fragments — move or rename it",
1505
+ reserved.display()
1506
+ )));
1507
+ }
1508
+
1509
+ let mut page_files = Vec::new();
1510
+ Self::collect_page_files(&pages_dir, &self.config.skip_files, &mut page_files);
1511
+ page_files.sort();
1512
+ if page_files.is_empty() {
1513
+ return Err(CompileError::SpaError(format!(
1514
+ "found no pages under {}",
1515
+ pages_dir.display()
1516
+ )));
1517
+ }
1518
+
1519
+ // Raw pre-pass: read every page and detect the shared-layout shape
1520
+ // BEFORE component inlining consumes the <component src> root.
1521
+ let mut raw_pages = Vec::new();
1522
+ for path in &page_files {
1523
+ let rel = path
1524
+ .strip_prefix(&pages_dir)
1525
+ .unwrap()
1526
+ .to_string_lossy()
1527
+ .replace('\\', "/");
1528
+ let content = fs::read_to_string(path).map_err(|e| CompileError::ReadError {
1529
+ path: path.display().to_string(),
1530
+ source: e,
1531
+ })?;
1532
+ let candidate = spa::detect_layout_root(&content);
1533
+ raw_pages.push((rel, content, candidate));
1534
+ }
1535
+ let candidates: Vec<_> = raw_pages.iter().map(|(_, _, c)| c.clone()).collect();
1536
+ let hoisted = spa::hoistable_layout(&candidates);
1537
+ if self.verbose {
1538
+ if let Some((_, src)) = &hoisted {
1539
+ println!(" spa: hoisted shared layout {} into the shell", src);
1540
+ }
1541
+ }
1542
+
1543
+ let mut pages = Vec::new();
1544
+ // Disjoint build-id ranges per compile unit: the shell tags from 0,
1545
+ // fragment i from (i + 1) * 1000. All units share one live document at
1546
+ // runtime — the shell owns its chrome components for the app's
1547
+ // lifetime, and a fragment tagged from the same base would claim those
1548
+ // ids on mount (stomping shell state) and release them on unmount
1549
+ // (killing shell bindings).
1550
+ const SPA_UNIT_ID_STRIDE: usize = 1000;
1551
+ for (page_index, (rel, content, candidate)) in raw_pages.into_iter().enumerate() {
1552
+ // Hoisted mode compiles the REDUCED page (layout replaced by its
1553
+ // slot content) through the unchanged pipeline — the fragment
1554
+ // comes out as just the per-page content.
1555
+ let content = match (&hoisted, candidate) {
1556
+ (Some(_), Some((_, _, reduced))) => reduced,
1557
+ _ => content,
1558
+ };
1559
+ let processed = parser.process_html_with_cache(
1560
+ &content,
1561
+ self.config.elements_as_is,
1562
+ &self.config.reserved_elements,
1563
+ self.config.components_as_is,
1564
+ &self.config.components,
1565
+ &self.component_cache,
1566
+ );
1567
+ // Build-inlined children get the compiled-document treatment:
1568
+ // tagged wrapper ids + this.→_cN rewrites, matching their
1569
+ // vibe-module scripts (the runtime's mounted-subtree pass claims
1570
+ // ids via closest wrapper). Same protect→tag order as the
1571
+ // manifest pipeline; the extracted state is a page concern and
1572
+ // fragments have none to stamp.
1573
+ let protected = crate::compiler::name_binding_protect::protect(&processed);
1574
+ let id_base = (page_index + 1) * SPA_UNIT_ID_STRIDE;
1575
+ let tagged =
1576
+ crate::compiler::component_tagger::ComponentTagger::tag_components_from(
1577
+ &protected,
1578
+ &self.config.source,
1579
+ id_base,
1580
+ )
1581
+ .map_err(CompileError::SpaError)?;
1582
+ if tagged.next_id > id_base + SPA_UNIT_ID_STRIDE {
1583
+ return Err(CompileError::SpaError(format!(
1584
+ "pages/{} inlines {} components — more than the {} ids allotted per page",
1585
+ rel,
1586
+ tagged.next_id - id_base,
1587
+ SPA_UNIT_ID_STRIDE
1588
+ )));
1589
+ }
1590
+ let page = spa::dissect_page(&rel, &tagged.html).map_err(CompileError::SpaError)?;
1591
+
1592
+ if let Some(api) = page.hygiene_offender {
1593
+ eprintln!(
1594
+ "{}: pages/{} starts side effects ({}) without an $.on('unmount') teardown — under SPA these outlive navigation",
1595
+ "Warning".yellow(),
1596
+ rel,
1597
+ api
1598
+ );
1599
+ }
1600
+
1601
+ let fragment_path = self
1602
+ .config
1603
+ .output
1604
+ .join("components")
1605
+ .join("vibe-spa")
1606
+ .join(&rel);
1607
+ if let Some(parent) = fragment_path.parent() {
1608
+ fs::create_dir_all(parent).map_err(|_| {
1609
+ CompileError::CreateDirError(parent.display().to_string())
1610
+ })?;
1611
+ }
1612
+ atomic_write(&fragment_path, &page.fragment).map_err(|e| CompileError::WriteError {
1613
+ path: fragment_path.display().to_string(),
1614
+ source: e,
1615
+ })?;
1616
+
1617
+ pages.push(page);
1618
+ }
1619
+
1620
+ // Silent route collisions are unshippable: `foo.html` and
1621
+ // `foo/index.html` both map to /foo, and stable-sort order would
1622
+ // decide which page silently shadows the other. Hard error, naming
1623
+ // both files (the reserved-dir check above set the precedent).
1624
+ {
1625
+ let mut seen: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
1626
+ for page in &pages {
1627
+ if let Some(first) = seen.insert(page.route.as_str(), page.rel_path.as_str()) {
1628
+ return Err(CompileError::SpaError(format!(
1629
+ "route collision: pages/{} and pages/{} both map to {} — rename one",
1630
+ first,
1631
+ page.rel_path,
1632
+ if page.route == "*" { "the '*' fallback (404)".to_string() } else { page.route.clone() }
1633
+ )));
1634
+ }
1635
+ }
1636
+ }
1637
+
1638
+ // Build-id ranges are 1000 per unit above a shell base of 0, and the
1639
+ // runtime mints its own ids from 1,000,000 — page index 999 would
1640
+ // collide with the runtime floor. Nobody has a thousand pages; if you
1641
+ // do, raise the runtime floor first.
1642
+ if (pages.len() + 1) * SPA_UNIT_ID_STRIDE > 1_000_000 {
1643
+ return Err(CompileError::SpaError(format!(
1644
+ "{} pages exceeds the compiled id space ({} ids per page below the runtime's 1,000,000 floor)",
1645
+ pages.len(),
1646
+ SPA_UNIT_ID_STRIDE
1647
+ )));
1648
+ }
1649
+
1650
+ // The fragments dir mirrors the pages tree: prune fragments whose
1651
+ // page no longer exists (watch-mode page removals, no-clean runs).
1652
+ let fragments_root = self.config.output.join("components").join("vibe-spa");
1653
+ let live: HashSet<String> = pages.iter().map(|p| p.rel_path.clone()).collect();
1654
+ let mut existing = Vec::new();
1655
+ Self::collect_page_files(&fragments_root, &self.config.skip_files, &mut existing);
1656
+ for stale in existing {
1657
+ let rel = stale
1658
+ .strip_prefix(&fragments_root)
1659
+ .unwrap()
1660
+ .to_string_lossy()
1661
+ .replace('\\', "/");
1662
+ if !live.contains(&rel) {
1663
+ let _ = fs::remove_file(&stale);
1664
+ }
1665
+ }
1666
+
1667
+ let order = spa::route_order(&pages);
1668
+ let shell = spa::compose_shell(&pages, &order, hoisted.as_ref());
1669
+ if self.verbose {
1670
+ for note in &shell.notes {
1671
+ println!(" spa: {}", note);
1672
+ }
1673
+ }
1674
+
1675
+ if self.config.source.join("index.html").exists() {
1676
+ eprintln!(
1677
+ "{}: SPA shell overwrites the compiled root index.html (source has its own index.html outside the pages tree)",
1678
+ "Warning".yellow()
1679
+ );
1680
+ }
1681
+
1682
+ // The shell flows through the same component pipeline as pages so a
1683
+ // hoisted layout is build-inlined (tagged wrappers + vibe-module
1684
+ // scripts) instead of runtime-fetched piecemeal at boot.
1685
+ let shell_processed = if hoisted.is_some() {
1686
+ let processed = parser.process_html_with_cache(
1687
+ &shell.html,
1688
+ self.config.elements_as_is,
1689
+ &self.config.reserved_elements,
1690
+ self.config.components_as_is,
1691
+ &self.config.components,
1692
+ &self.component_cache,
1693
+ );
1694
+ let protected = crate::compiler::name_binding_protect::protect(&processed);
1695
+ let tagged = crate::compiler::component_tagger::ComponentTagger::tag_components(
1696
+ &protected,
1697
+ &self.config.source,
1698
+ )
1699
+ .map_err(CompileError::SpaError)?;
1700
+ if tagged.next_id > SPA_UNIT_ID_STRIDE {
1701
+ return Err(CompileError::SpaError(format!(
1702
+ "the hoisted layout inlines {} components — more than the {} ids allotted to the shell",
1703
+ tagged.next_id, SPA_UNIT_ID_STRIDE
1704
+ )));
1705
+ }
1706
+ tagged.html
1707
+ } else {
1708
+ shell.html
1709
+ };
1710
+ let shell_html = if self.config.minify {
1711
+ minify_html(&shell_processed)
1712
+ } else {
1713
+ shell_processed
1714
+ };
1715
+ let shell_path = self.config.output.join("index.html");
1716
+ atomic_write(&shell_path, &shell_html).map_err(|e| CompileError::WriteError {
1717
+ path: shell_path.display().to_string(),
1718
+ source: e,
1719
+ })?;
1720
+ // The shell rides the normal manifest pipeline like any page.
1721
+ self.compiled_html.insert(shell_path, shell_html);
1722
+
1723
+ Ok(pages.len() + 1)
1724
+ }
1725
+
1726
+ /// Regenerate the shell's manifest after a watch-mode recompose (the full
1727
+ /// build gets it via generate_manifests; incremental recompiles target
1728
+ /// only changed files, and the shell has no source counterpart to list).
1729
+ /// Manifest yes, stamp no — same rule as the full pipeline.
1730
+ pub(crate) fn generate_shell_manifest(&self) -> Result<(), CompileError> {
1731
+ let shell_path = self.config.output.join("index.html");
1732
+ let disk_html;
1733
+ let html: &str = match self.compiled_html.get(&shell_path) {
1734
+ Some(html) => html,
1735
+ None => {
1736
+ disk_html = fs::read_to_string(&shell_path).map_err(|e| CompileError::ReadError {
1737
+ path: shell_path.display().to_string(),
1738
+ source: e,
1739
+ })?;
1740
+ &disk_html
1741
+ }
1742
+ };
1743
+ Self::generate_file_manifest(
1744
+ html,
1745
+ &shell_path,
1746
+ &self.config.output,
1747
+ "index.html",
1748
+ self.verbose,
1749
+ self.config.iterations_as_is,
1750
+ self.config.components_as_is,
1751
+ &self.config.source,
1752
+ self.config.root.as_deref(),
1753
+ &self.compute_global_constants(),
1754
+ false,
1755
+ )
1756
+ .map_err(CompileError::SpaError)
1757
+ }
1758
+
1759
+ fn extract_components(&self, content: &str) -> (usize, usize, Vec<String>) {
1760
+ let mut internal = 0;
1761
+ let mut external = 0;
1762
+ let mut srcs = Vec::new();
1763
+
1764
+ // Simple regex-based extraction of <component src="..."> tags
1765
+ let re = Regex::new(r#"<component[^>]+src\s*=\s*["']([^"']+)["']"#).unwrap();
1766
+ for cap in re.captures_iter(content) {
1767
+ if let Some(src) = cap.get(1) {
1768
+ let src_str = src.as_str().to_string();
1769
+ if src_str.starts_with("http://") || src_str.starts_with("https://") {
1770
+ external += 1;
1771
+ } else {
1772
+ internal += 1;
1773
+ }
1774
+ srcs.push(src_str);
1775
+ }
1776
+ }
1777
+
1778
+ (internal, external, srcs)
1779
+ }
1780
+
1781
+ fn build_component_relationships(&mut self, component_srcs: &[String]) -> Vec<(String, Vec<String>)> {
1782
+ let mut relationships = Vec::new();
1783
+ let mut visited = HashSet::new();
1784
+
1785
+ for src in component_srcs {
1786
+ self.collect_component_children(src, &mut relationships, &mut visited);
1787
+ }
1788
+
1789
+ relationships
1790
+ }
1791
+
1792
+ fn collect_component_children(&mut self, component_src: &str, relationships: &mut Vec<(String, Vec<String>)>, visited: &mut HashSet<String>) {
1793
+ if visited.contains(component_src) {
1794
+ return;
1795
+ }
1796
+ visited.insert(component_src.to_string());
1797
+
1798
+ // Read component content
1799
+ let content = if component_src.starts_with("http://") || component_src.starts_with("https://") {
1800
+ match self.fetch_external_component_raw(component_src) {
1801
+ Ok(c) => c,
1802
+ Err(_) => return,
1803
+ }
1804
+ } else {
1805
+ match self.resolve_component_path(component_src) {
1806
+ Ok(path) => match fs::read_to_string(&path) {
1807
+ Ok(c) => c,
1808
+ Err(_) => return,
1809
+ },
1810
+ Err(_) => return,
1811
+ }
1812
+ };
1813
+
1814
+ // Extract immediate children
1815
+ let children = self.extract_component_srcs(&content);
1816
+
1817
+ // Add this component and its children to relationships
1818
+ relationships.push((component_src.to_string(), children.clone()));
1819
+
1820
+ // Recursively collect children's relationships
1821
+ for child in children {
1822
+ self.collect_component_children(&child, relationships, visited);
1823
+ }
1824
+ }
1825
+
1826
+
1827
+ /// MIRROR_MODE: Copy file to output, preserving relative path
1828
+ fn copy_file(&self, path: &Path, relative_path: &str) -> Result<(), CompileError> {
1829
+ let output_path = self.get_output_path(path, relative_path)?;
1830
+
1831
+ fs::copy(path, &output_path).map_err(|e| CompileError::WriteError {
1832
+ path: output_path.display().to_string(),
1833
+ source: e,
1834
+ })?;
1835
+
1836
+ Ok(())
1837
+ }
1838
+
1839
+ /// Re-mirror specific source files under the components directory into the
1840
+ /// output. The full build always mirrors components/ verbatim (see
1841
+ /// process_directory_assets_only): runtime-fetched components —
1842
+ /// `<component src="@[page.src]">` targets, iter-prop each-roots,
1843
+ /// components_as_is — are served from that mirror at request time. Watch
1844
+ /// mode calls this for every changed component so the mirror tracks edits
1845
+ /// and deletions even when no page inlines the component (zero graph
1846
+ /// dependents). Writes are atomic (same tmp+rename as compiled pages) so a
1847
+ /// runtime fetch mid-copy never sees a torn file. Returns how many files
1848
+ /// were copied.
1849
+ pub fn mirror_component_files(&self, files: &[PathBuf]) -> Result<usize, CompileError> {
1850
+ let mut copied = 0;
1851
+ for path in files {
1852
+ let relative = path.strip_prefix(&self.config.source).unwrap_or(path);
1853
+ let output_path = self.config.output.join(relative);
1854
+ if path.exists() {
1855
+ if let Some(parent) = output_path.parent() {
1856
+ fs::create_dir_all(parent).map_err(|e| CompileError::WriteError {
1857
+ path: parent.display().to_string(),
1858
+ source: e,
1859
+ })?;
1860
+ }
1861
+ let contents = fs::read_to_string(path).map_err(|e| CompileError::ReadError {
1862
+ path: path.display().to_string(),
1863
+ source: e,
1864
+ })?;
1865
+ atomic_write(&output_path, &contents).map_err(|e| CompileError::WriteError {
1866
+ path: output_path.display().to_string(),
1867
+ source: e,
1868
+ })?;
1869
+ copied += 1;
1870
+ } else if output_path.exists() {
1871
+ fs::remove_file(&output_path).map_err(|e| CompileError::WriteError {
1872
+ path: output_path.display().to_string(),
1873
+ source: e,
1874
+ })?;
1875
+ }
1876
+ }
1877
+ Ok(copied)
1878
+ }
1879
+
1880
+ /// Fetch components only for specific files (used in incremental compilation)
1881
+ fn fetch_components_for_files(&mut self, files: &[PathBuf], parser: &HtmlParser) -> Result<(), CompileError> {
1882
+ for html_file in files {
1883
+ let content = match fs::read_to_string(html_file) {
1884
+ Ok(c) => c,
1885
+ Err(e) => {
1886
+ if self.verbose {
1887
+ eprintln!(" Warning: Failed to read {}: {}", html_file.display(), e);
1888
+ }
1889
+ continue;
1890
+ }
1891
+ };
1892
+
1893
+ // Transform custom tags to <component> tags
1894
+ let transformed = parser.process_html(
1895
+ &content,
1896
+ self.config.elements_as_is,
1897
+ &self.config.reserved_elements,
1898
+ true, // components_as_is (don't inline, just transform)
1899
+ &self.config.components,
1900
+ );
1901
+
1902
+ // Extract and fetch components recursively
1903
+ let components = self.extract_component_srcs(&transformed);
1904
+ for component_src in components {
1905
+ let mut visiting = std::collections::HashSet::new();
1906
+ self.fetch_component_recursive(&component_src, parser, &mut visiting);
1907
+ }
1908
+ }
1909
+
1910
+ Ok(())
1911
+ }
1912
+
1913
+ /// Fetch and cache all components for inlining (separate from validation)
1914
+ /// This recursively fetches all components (external and internal) and populates component_cache
1915
+ fn fetch_all_components(&mut self, parser: &HtmlParser) -> Result<(), CompileError> {
1916
+ // Find all HTML files in source directory (need to scan pages to find external component references)
1917
+ let html_files = self.find_all_html_files(&self.config.source)?;
1918
+
1919
+ // Scan each HTML file for components
1920
+ for html_file in html_files {
1921
+ let content = match fs::read_to_string(&html_file) {
1922
+ Ok(c) => c,
1923
+ Err(e) => {
1924
+ if self.verbose {
1925
+ eprintln!(" Warning: Failed to read {}: {}", html_file.display(), e);
1926
+ }
1927
+ continue;
1928
+ }
1929
+ };
1930
+
1931
+ // Transform custom tags to <component> tags
1932
+ let transformed = parser.process_html(
1933
+ &content,
1934
+ self.config.elements_as_is,
1935
+ &self.config.reserved_elements,
1936
+ true, // components_as_is (don't inline, just transform)
1937
+ &self.config.components,
1938
+ );
1939
+
1940
+ // Extract and fetch components recursively
1941
+ let components = self.extract_component_srcs(&transformed);
1942
+ for component_src in components {
1943
+ let mut visiting = std::collections::HashSet::new();
1944
+ self.fetch_component_recursive(&component_src, parser, &mut visiting);
1945
+ }
1946
+ }
1947
+
1948
+ Ok(())
1949
+ }
1950
+
1951
+ /// Recursively fetch a component and its nested components (for inlining).
1952
+ /// Returns the fully inlined content (all nested components resolved).
1953
+ ///
1954
+ /// `visiting` tracks the chain of components currently being fetched. A
1955
+ /// `<component src>` that points back into the chain is a cycle (direct
1956
+ /// self-reference, or A→B→A) — those are valid runtime patterns (recursive
1957
+ /// tree components bounded by data depth) but would expand infinitely at
1958
+ /// compile time. Cycle references are escaped to `data-vibe-recursive-src`
1959
+ /// in the cached content so the inliner's `src=` regex won't keep matching
1960
+ /// them; `process_html_with_cache` restores the attribute name at the end
1961
+ /// of compilation so the runtime sees a normal `<component src>` tag.
1962
+ fn fetch_component_recursive(
1963
+ &mut self,
1964
+ component_src: &str,
1965
+ parser: &HtmlParser,
1966
+ visiting: &mut std::collections::HashSet<String>,
1967
+ ) -> Option<String> {
1968
+ // Normalize path: ensure it starts with / (unless it's a URL)
1969
+ let normalized_src = if component_src.starts_with("http://") || component_src.starts_with("https://") {
1970
+ component_src.to_string()
1971
+ } else {
1972
+ let without_prefix = component_src.trim_start_matches("./");
1973
+ if without_prefix.starts_with('/') {
1974
+ without_prefix.to_string()
1975
+ } else {
1976
+ format!("/{}", without_prefix)
1977
+ }
1978
+ };
1979
+
1980
+ // Return cached if already fetched and fully resolved
1981
+ if let Some(cached) = self.component_cache.get(&normalized_src) {
1982
+ return Some(cached.clone());
1983
+ }
1984
+
1985
+ // Cycle: this component is already higher in the fetch chain. Don't
1986
+ // recurse — the caller will leave the `<component src>` tag as-is and
1987
+ // the runtime handles the recursion with real data bounds.
1988
+ if visiting.contains(&normalized_src) {
1989
+ return None;
1990
+ }
1991
+
1992
+ // Fetch external or read internal component (raw content)
1993
+ let content = if normalized_src.starts_with("http://") || normalized_src.starts_with("https://") {
1994
+ match self.fetch_external_component_raw(&normalized_src) {
1995
+ Ok(c) => c,
1996
+ Err(e) => {
1997
+ if self.verbose {
1998
+ eprintln!(" Warning: Failed to fetch {}: {}", normalized_src, e);
1999
+ }
2000
+ return None;
2001
+ }
2002
+ }
2003
+ } else {
2004
+ match self.resolve_component_path(&normalized_src) {
2005
+ Ok(path) => match fs::read_to_string(&path) {
2006
+ Ok(c) => c,
2007
+ Err(e) => {
2008
+ if self.verbose {
2009
+ eprintln!(" Warning: Failed to read component {}: {}", normalized_src, e);
2010
+ }
2011
+ return None;
2012
+ }
2013
+ },
2014
+ Err(e) => {
2015
+ if self.verbose {
2016
+ eprintln!(" Warning: {}", e);
2017
+ }
2018
+ return None;
2019
+ }
2020
+ }
2021
+ };
2022
+
2023
+ // Transform custom tags in component (but keep component tags as-is for now)
2024
+ let mut transformed = parser.process_html(
2025
+ &content,
2026
+ self.config.elements_as_is, // respect global config
2027
+ &self.config.reserved_elements.clone(), // respect global config
2028
+ true, // components_as_is = true (keep <component> tags for now so we can recursively resolve them)
2029
+ &self.config.components,
2030
+ );
2031
+
2032
+ // Mark this component as in-flight before recursing into its nested
2033
+ // refs. Cycles (direct self-reference or A→B→A chains) are detected
2034
+ // by the visiting check at the top of this function.
2035
+ visiting.insert(normalized_src.clone());
2036
+
2037
+ // Recursively fetch and inline all nested components
2038
+ let nested = self.extract_component_srcs(&transformed);
2039
+ for nested_src in nested {
2040
+ if let Some(nested_content) = self.fetch_component_recursive(&nested_src, parser, visiting) {
2041
+ // Inline this nested component into the current component
2042
+ transformed = parser.inline_single_component(&transformed, &nested_src, &nested_content);
2043
+ }
2044
+ }
2045
+
2046
+ visiting.remove(&normalized_src);
2047
+
2048
+ // Any `<component src>` still in `transformed` whose target is in the
2049
+ // visiting chain (a cycle) didn't get inlined. Escape its `src=` so
2050
+ // `inline_component_elements` won't keep re-expanding it forever when
2051
+ // a page inlines this cached content. `process_html_with_cache`
2052
+ // restores the attribute to plain `src=` at the end of compilation so
2053
+ // the runtime fetches it normally.
2054
+ for ancestor in visiting.iter() {
2055
+ transformed = escape_recursive_src(&transformed, ancestor);
2056
+ }
2057
+ // Also escape direct self-references — a component pointing at itself
2058
+ // never made it into `visiting` (the cycle check short-circuited
2059
+ // before insertion), so handle it explicitly.
2060
+ transformed = escape_recursive_src(&transformed, &normalized_src);
2061
+
2062
+ // Cache the fully resolved content
2063
+ self.component_cache.insert(normalized_src.clone(), transformed.clone());
2064
+
2065
+ Some(transformed)
2066
+ }
2067
+
2068
+ /// Validate that component filenames don't match reserved elements
2069
+ fn validate_component_names(&self) -> Result<(), CompileError> {
2070
+ let components_dir = self.config.components_path();
2071
+
2072
+ if !components_dir.exists() {
2073
+ return Ok(()); // No components directory
2074
+ }
2075
+
2076
+ let component_files = self.find_all_html_files(&components_dir)?;
2077
+
2078
+ for component_file in component_files {
2079
+ let file_name = component_file
2080
+ .file_stem()
2081
+ .and_then(|s| s.to_str())
2082
+ .unwrap_or("");
2083
+
2084
+ // Check if filename (case-sensitive) matches any reserved element
2085
+ if self.config.reserved_elements.contains(&file_name.to_string()) {
2086
+ let relative_path = component_file
2087
+ .strip_prefix(&self.config.source)
2088
+ .unwrap_or(&component_file);
2089
+
2090
+ return Err(CompileError::ReservedComponentName {
2091
+ component_name: file_name.to_string(),
2092
+ file_path: relative_path.display().to_string(),
2093
+ });
2094
+ }
2095
+ }
2096
+
2097
+ Ok(())
2098
+ }
2099
+
2100
+ /// Validate HTML syntax for all files (controlled by validate flag)
2101
+ fn validate_html_syntax(&self) -> Result<(), CompileError> {
2102
+ // Find all HTML files in source directory
2103
+ let html_files = self.find_all_html_files(&self.config.source)?;
2104
+
2105
+ for html_file in html_files {
2106
+ let content = fs::read_to_string(&html_file).map_err(|e| CompileError::ReadError {
2107
+ path: html_file.display().to_string(),
2108
+ source: e,
2109
+ })?;
2110
+
2111
+ // Validate HTML syntax
2112
+ self.validate_html(&content, &html_file)?;
2113
+ }
2114
+
2115
+ Ok(())
2116
+ }
2117
+
2118
+ /// Fetch external component without caching (returns raw content)
2119
+ fn fetch_external_component_raw(&self, url: &str) -> Result<String, String> {
2120
+ match ureq::get(url).call() {
2121
+ Ok(response) => response.into_string().map_err(|e| format!("Failed to read response body: {}", e)),
2122
+ Err(ureq::Error::Status(code, response)) => Err(format!("HTTP {} - {}", code, response.status_text().to_string())),
2123
+ Err(e) => Err(format!("Failed to fetch: {}", e)),
2124
+ }
2125
+ }
2126
+
2127
+ fn resolve_component_path(&self, component_src: &str) -> Result<PathBuf, String> {
2128
+ // Normalize path (remove leading ./ or /)
2129
+ let normalized = component_src.trim_start_matches("./").trim_start_matches('/');
2130
+
2131
+ // Try relative to source root
2132
+ let path = self.config.source.join(normalized);
2133
+ if path.exists() {
2134
+ return Ok(path);
2135
+ }
2136
+
2137
+ Err(format!("Component file not found: {}", component_src))
2138
+ }
2139
+
2140
+ fn extract_component_srcs(&self, content: &str) -> Vec<String> {
2141
+ let mut srcs = Vec::new();
2142
+ // Match both <component src="..."> and <div class="component" src="...">
2143
+ // After elements_as_is=false transform, <component> becomes <div class="component">
2144
+ let re = Regex::new(r#"(?:<component|<div\s+class="component")[^>]+src\s*=\s*["']([^"']+)["']"#).unwrap();
2145
+ for cap in re.captures_iter(content) {
2146
+ if let Some(src) = cap.get(1) {
2147
+ srcs.push(src.as_str().to_string());
2148
+ }
2149
+ }
2150
+ srcs
2151
+ }
2152
+
2153
+ fn find_all_html_files(&self, dir: &Path) -> Result<Vec<PathBuf>, CompileError> {
2154
+ let mut html_files = Vec::new();
2155
+ // Get output directory basename to skip nested directories with same name
2156
+ let output_dir_name = self.config.output.file_name()
2157
+ .and_then(|n| n.to_str())
2158
+ .unwrap_or("");
2159
+ self.find_html_files_recursive(dir, &mut html_files, output_dir_name)?;
2160
+ Ok(html_files)
2161
+ }
2162
+
2163
+ fn find_html_files_recursive(&self, dir: &Path, files: &mut Vec<PathBuf>, output_dir_name: &str) -> Result<(), CompileError> {
2164
+ if !dir.is_dir() {
2165
+ return Ok(());
2166
+ }
2167
+
2168
+ let entries = match fs::read_dir(dir) {
2169
+ Ok(e) => e,
2170
+ Err(_) => return Ok(()), // Skip directories we can't read
2171
+ };
2172
+
2173
+ for entry in entries {
2174
+ let entry = match entry {
2175
+ Ok(e) => e,
2176
+ Err(_) => continue,
2177
+ };
2178
+
2179
+ let path = entry.path();
2180
+ let file_name = entry.file_name();
2181
+ let file_name_str = file_name.to_string_lossy();
2182
+
2183
+ // Skip specific directories/patterns (includes dotfiles via SKIP_FILES)
2184
+ if should_skip_path(&path, &file_name_str, &self.config.skip_files) {
2185
+ continue;
2186
+ }
2187
+
2188
+ // Skip components directory during initial scan (we'll validate them when referenced)
2189
+ if file_name_str == self.config.components {
2190
+ continue;
2191
+ }
2192
+
2193
+ // Skip nested directories with same name as output dir (from previous bad compilations)
2194
+ if !output_dir_name.is_empty() && file_name_str == output_dir_name {
2195
+ continue;
2196
+ }
2197
+
2198
+ if path.is_dir() {
2199
+ self.find_html_files_recursive(&path, files, output_dir_name)?;
2200
+ } else if path.extension().and_then(|e| e.to_str()) == Some("html") {
2201
+ files.push(path);
2202
+ }
2203
+ }
2204
+
2205
+ Ok(())
2206
+ }
2207
+
2208
+ fn get_output_path(&self, path: &Path, relative_path: &str) -> Result<PathBuf, CompileError> {
2209
+ let file_name = path.file_name().unwrap();
2210
+
2211
+ let output_path = if relative_path.is_empty() {
2212
+ self.config.output.join(file_name)
2213
+ } else {
2214
+ let output_subdir = self.config.output.join(relative_path);
2215
+ fs::create_dir_all(&output_subdir).map_err(|_| {
2216
+ CompileError::CreateDirError(output_subdir.display().to_string())
2217
+ })?;
2218
+ output_subdir.join(file_name)
2219
+ };
2220
+
2221
+ Ok(output_path)
2222
+ }
2223
+
2224
+ fn validate_html(&self, content: &str, path: &Path) -> Result<(), CompileError> {
2225
+ // Check 1: Unclosed attribute quotes
2226
+ self.validate_quote_balance(content, path)?;
2227
+
2228
+ // Check 2: Tag balance (unclosed elements break slot extraction)
2229
+ self.validate_tag_balance(content, path)
2230
+ }
2231
+
2232
+ /// Quotes only matter inside tags and attribute values may span lines;
2233
+ /// comments and raw content (script, style, pre) are skipped entirely.
2234
+ fn validate_quote_balance(&self, content: &str, path: &Path) -> Result<(), CompileError> {
2235
+ const RAW_CONTENT: &[&str] = &["script", "style", "pre"];
2236
+
2237
+ let bytes = content.as_bytes();
2238
+ let mut i = 0;
2239
+ let mut line = 1;
2240
+
2241
+ while i < bytes.len() {
2242
+ if bytes[i] == b'\n' {
2243
+ line += 1;
2244
+ i += 1;
2245
+ } else if bytes[i..].starts_with(b"<!--") {
2246
+ match find_bytes(bytes, b"-->", i + 4) {
2247
+ Some(end) => {
2248
+ line += count_newlines(&bytes[i..end + 3]);
2249
+ i = end + 3;
2250
+ }
2251
+ None => break,
2252
+ }
2253
+ } else if bytes[i] == b'<'
2254
+ && i + 1 < bytes.len()
2255
+ && (bytes[i + 1].is_ascii_alphabetic() || bytes[i + 1] == b'/' || bytes[i + 1] == b'!')
2256
+ {
2257
+ let is_closing = bytes[i + 1] == b'/';
2258
+ let name_start = i + if is_closing { 2 } else { 1 };
2259
+ let name_end = bytes[name_start..]
2260
+ .iter()
2261
+ .position(|b| !(b.is_ascii_alphanumeric() || *b == b'-'))
2262
+ .map_or(bytes.len(), |p| name_start + p);
2263
+ let name = content[name_start..name_end].to_ascii_lowercase();
2264
+ i = name_end;
2265
+
2266
+ let mut quote: Option<(u8, usize)> = None;
2267
+ let mut self_closing = false;
2268
+ while i < bytes.len() {
2269
+ let b = bytes[i];
2270
+ if b == b'\n' {
2271
+ line += 1;
2272
+ }
2273
+ match quote {
2274
+ Some((q, _)) if b == q => quote = None,
2275
+ Some(_) => {}
2276
+ None => match b {
2277
+ b'"' | b'\'' => quote = Some((b, line)),
2278
+ b'>' => {
2279
+ self_closing = bytes[i - 1] == b'/';
2280
+ break;
2281
+ }
2282
+ _ => {}
2283
+ },
2284
+ }
2285
+ i += 1;
2286
+ }
2287
+
2288
+ if let Some((_, quote_line)) = quote {
2289
+ return Err(CompileError::ValidationError {
2290
+ file: path.display().to_string(),
2291
+ line: quote_line,
2292
+ message: "Unclosed quote".to_string(),
2293
+ });
2294
+ }
2295
+ if i >= bytes.len() {
2296
+ break;
2297
+ }
2298
+ i += 1;
2299
+
2300
+ if !is_closing && !self_closing && RAW_CONTENT.contains(&name.as_str()) {
2301
+ let close = format!("</{}", name);
2302
+ match find_bytes_ci(bytes, close.as_bytes(), i) {
2303
+ Some(pos) => {
2304
+ line += count_newlines(&bytes[i..pos]);
2305
+ i = pos;
2306
+ }
2307
+ None => break, // unterminated raw block — tag balance reports it
2308
+ }
2309
+ }
2310
+ } else {
2311
+ i += 1;
2312
+ }
2313
+ }
2314
+
2315
+ Ok(())
2316
+ }
2317
+
2318
+ fn validate_tag_balance(&self, content: &str, path: &Path) -> Result<(), CompileError> {
2319
+ const VOID_ELEMENTS: &[&str] = &[
2320
+ "area", "base", "br", "col", "embed", "hr", "img", "input",
2321
+ "link", "meta", "param", "source", "track", "wbr",
2322
+ ];
2323
+ // These are implicitly closed by the parser — don't require explicit close tags
2324
+ const IMPLICIT_CLOSE: &[&str] = &[
2325
+ "html", "head", "body", "p", "li", "dt", "dd", "option",
2326
+ "optgroup", "tr", "td", "th", "thead", "tbody", "tfoot", "colgroup",
2327
+ ];
2328
+
2329
+ // Strip comments, raw content blocks, and @[...] bindings before tag scanning
2330
+ let stripped = Self::strip_for_tag_validation(content);
2331
+
2332
+ // Match any tag: opening, self-closing, or closing
2333
+ let tag_re = Regex::new(r"</?([a-zA-Z][a-zA-Z0-9-]*)(?:\s[^>]*)?>").unwrap();
2334
+
2335
+ // Count opens vs closes per tag name; track line of first open for error reporting
2336
+ let mut counts: HashMap<String, (i32, usize)> = HashMap::new();
2337
+ for cap in tag_re.captures_iter(&stripped) {
2338
+ let full = cap.get(0).unwrap().as_str();
2339
+ let name = cap[1].to_lowercase();
2340
+ if VOID_ELEMENTS.contains(&name.as_str()) || IMPLICIT_CLOSE.contains(&name.as_str()) {
2341
+ continue;
2342
+ }
2343
+ if full.starts_with("</") {
2344
+ counts.entry(name).and_modify(|(c, _)| *c -= 1);
2345
+ } else if full.ends_with("/>") {
2346
+ // self-closing — no balance change
2347
+ } else {
2348
+ let offset = cap.get(0).unwrap().start();
2349
+ let line = stripped[..offset].matches('\n').count() + 1;
2350
+ let entry = counts.entry(name).or_insert((0, line));
2351
+ entry.0 += 1;
2352
+ }
2353
+ }
2354
+
2355
+ // Report the first unclosed tag (sorted by line for deterministic output)
2356
+ let mut unclosed: Vec<(String, usize)> = counts.into_iter()
2357
+ .filter(|(_, (count, _))| *count > 0)
2358
+ .map(|(name, (_, line))| (name, line))
2359
+ .collect();
2360
+ unclosed.sort_by_key(|(_, line)| *line);
2361
+
2362
+ if let Some((name, line)) = unclosed.first() {
2363
+ return Err(CompileError::ValidationError {
2364
+ file: path.display().to_string(),
2365
+ line: *line,
2366
+ message: format!("Unclosed tag <{}>", name),
2367
+ });
2368
+ }
2369
+
2370
+ Ok(())
2371
+ }
2372
+
2373
+ fn strip_for_tag_validation(content: &str) -> String {
2374
+ // Strip HTML comments <!-- ... --> (includes Vibe syntax: each, if, else, /if, /each)
2375
+ let comment_re = Regex::new(r"(?s)<!--.*?-->").unwrap();
2376
+ let s = comment_re.replace_all(content, "");
2377
+ // Strip @[...] reactive bindings (can contain < and > characters)
2378
+ let binding_re = Regex::new(r"@\[[^\]]*\]").unwrap();
2379
+ let s = binding_re.replace_all(&s, "");
2380
+ // Strip content inside <script>, <style>, <pre> to avoid parsing embedded code as HTML
2381
+ let raw_re = Regex::new(r"(?si)(<(?:script|style|pre)(?:\s[^>]*)?>).*?(</(?:script|style|pre)>)").unwrap();
2382
+ raw_re.replace_all(&s, "$1$2").into_owned()
2383
+ }
2384
+
2385
+ /// Copy an entire directory recursively
2386
+ fn copy_directory(
2387
+ &mut self,
2388
+ dir: &Path,
2389
+ relative_path: &str,
2390
+ canonical_source: &Path,
2391
+ stats: &mut CompileStats,
2392
+ ) -> Result<(), CompileError> {
2393
+ let entries = fs::read_dir(dir).map_err(|e| CompileError::ReadError {
2394
+ path: dir.display().to_string(),
2395
+ source: e,
2396
+ })?;
2397
+
2398
+ for entry in entries.flatten() {
2399
+ let path = entry.path();
2400
+ let file_name = path.file_name().unwrap().to_str().unwrap();
2401
+
2402
+ // Skip files/directories matching skip patterns
2403
+ if should_skip_path(&path, file_name, &self.config.skip_files) {
2404
+ continue;
2405
+ }
2406
+
2407
+ if path.is_dir() {
2408
+ let new_relative = format!("{}/{}", relative_path, file_name);
2409
+ self.copy_directory(&path, &new_relative, canonical_source, stats)?;
2410
+ } else {
2411
+ self.copy_file(&path, relative_path)?;
2412
+ stats.files_copied += 1;
2413
+ if let Some(ref mut logger) = self.logger {
2414
+ logger.log(&path, FileOperation::Copied, canonical_source);
2415
+ }
2416
+ }
2417
+ }
2418
+
2419
+ Ok(())
2420
+ }
2421
+
2422
+ /// Copy node_modules as-is to output
2423
+ fn copy_node_modules_as_is(&self) -> Result<(), CompileError> {
2424
+ let src = self.config.working_dir.join("node_modules");
2425
+ let dest = self.config.output.join("node_modules");
2426
+
2427
+ // Remove existing node_modules in output if it exists
2428
+ if dest.exists() {
2429
+ fs::remove_dir_all(&dest).map_err(|e| CompileError::WriteError {
2430
+ path: dest.display().to_string(),
2431
+ source: e,
2432
+ })?;
2433
+ }
2434
+
2435
+ // Copy recursively
2436
+ copy_dir_recursive(&src, &dest)?;
2437
+
2438
+ Ok(())
2439
+ }
2440
+
2441
+ /// Install production dependencies directly into output directory
2442
+ fn copy_production_node_modules(&self) -> Result<(), CompileError> {
2443
+ let pkg_manager = detect_package_manager(&self.config.working_dir)?;
2444
+
2445
+ if self.verbose {
2446
+ println!("\n Detected package manager: {}", pkg_manager);
2447
+ println!(" Installing production dependencies to output...");
2448
+ }
2449
+
2450
+ // 1. Copy package.json to output directory
2451
+ let package_json_src = self.config.working_dir.join("package.json");
2452
+ let package_json_dest = self.config.output.join("package.json");
2453
+
2454
+ fs::copy(&package_json_src, &package_json_dest).map_err(|e| CompileError::WriteError {
2455
+ path: package_json_dest.display().to_string(),
2456
+ source: e,
2457
+ })?;
2458
+
2459
+ // 2. Copy lockfile if it exists (needed for reproducible installs)
2460
+ let lockfile_name = match pkg_manager.as_str() {
2461
+ "bun" => "bun.lockb",
2462
+ "pnpm" => "pnpm-lock.yaml",
2463
+ "yarn" => "yarn.lock",
2464
+ "npm" => "package-lock.json",
2465
+ _ => "",
2466
+ };
2467
+
2468
+ if !lockfile_name.is_empty() {
2469
+ let lockfile_src = self.config.working_dir.join(lockfile_name);
2470
+ let lockfile_dest = self.config.output.join(lockfile_name);
2471
+ if lockfile_src.exists() {
2472
+ fs::copy(&lockfile_src, &lockfile_dest).ok();
2473
+ }
2474
+ }
2475
+
2476
+ // 3. Install production dependencies directly in output directory
2477
+ run_command(&pkg_manager, &["install", "--production"], &self.config.output)?;
2478
+
2479
+ // 4. Cleanup: remove package.json and lockfile from output
2480
+ fs::remove_file(&package_json_dest).ok();
2481
+ if !lockfile_name.is_empty() {
2482
+ let lockfile_dest = self.config.output.join(lockfile_name);
2483
+ fs::remove_file(&lockfile_dest).ok();
2484
+ }
2485
+
2486
+ if self.verbose {
2487
+ println!(" Installed production dependencies to output");
2488
+ }
2489
+
2490
+ Ok(())
2491
+ }
2492
+ }
2493
+
2494
+ /// Detect package manager from lockfiles
2495
+ fn detect_package_manager(dir: &Path) -> Result<String, CompileError> {
2496
+ if dir.join("bun.lockb").exists() {
2497
+ return Ok("bun".to_string());
2498
+ }
2499
+ if dir.join("pnpm-lock.yaml").exists() {
2500
+ return Ok("pnpm".to_string());
2501
+ }
2502
+ if dir.join("yarn.lock").exists() {
2503
+ return Ok("yarn".to_string());
2504
+ }
2505
+ if dir.join("package-lock.json").exists() {
2506
+ return Ok("npm".to_string());
2507
+ }
2508
+
2509
+ Err(CompileError::PackageManagerNotFound)
2510
+ }
2511
+
2512
+ /// Run a package manager command
2513
+ fn run_command(pkg_manager: &str, args: &[&str], cwd: &Path) -> Result<(), CompileError> {
2514
+ let output = Command::new(pkg_manager)
2515
+ .args(args)
2516
+ .current_dir(cwd)
2517
+ .output()
2518
+ .map_err(|e| CompileError::CommandError(format!("{} not found: {}", pkg_manager, e)))?;
2519
+
2520
+ if !output.status.success() {
2521
+ let stderr = String::from_utf8_lossy(&output.stderr);
2522
+ return Err(CompileError::CommandError(format!(
2523
+ "{} {} failed: {}",
2524
+ pkg_manager,
2525
+ args.join(" "),
2526
+ stderr
2527
+ )));
2528
+ }
2529
+
2530
+ Ok(())
2531
+ }
2532
+
2533
+ /// Recursively copy a directory
2534
+ fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), CompileError> {
2535
+ if !dest.exists() {
2536
+ fs::create_dir_all(dest).map_err(|_| CompileError::CreateDirError(dest.display().to_string()))?;
2537
+ }
2538
+
2539
+ for entry in fs::read_dir(src).map_err(|e| CompileError::ReadError {
2540
+ path: src.display().to_string(),
2541
+ source: e,
2542
+ })? {
2543
+ let entry = entry.map_err(|e| CompileError::ReadError {
2544
+ path: src.display().to_string(),
2545
+ source: e,
2546
+ })?;
2547
+ let path = entry.path();
2548
+ let file_name = entry.file_name();
2549
+ let dest_path = dest.join(&file_name);
2550
+
2551
+ if path.is_dir() {
2552
+ // Cache Directory Tagging spec: a directory carrying a signed
2553
+ // CACHEDIR.TAG (cargo target/, many build caches) declares itself
2554
+ // regenerable and skippable for copy tools. A symlinked local
2555
+ // package would otherwise drag gigabytes of build artifacts into
2556
+ // the as-is node_modules copy.
2557
+ if is_cachedir_tagged(&path) {
2558
+ continue;
2559
+ }
2560
+ copy_dir_recursive(&path, &dest_path)?;
2561
+ } else {
2562
+ fs::copy(&path, &dest_path).map_err(|e| CompileError::WriteError {
2563
+ path: dest_path.display().to_string(),
2564
+ source: e,
2565
+ })?;
2566
+ }
2567
+ }
2568
+
2569
+ Ok(())
2570
+ }
2571
+
2572
+ fn is_cachedir_tagged(dir: &Path) -> bool {
2573
+ fs::read(dir.join("CACHEDIR.TAG"))
2574
+ .map(|bytes| bytes.starts_with(b"Signature: 8a477f597d28d172789f06886806bc55"))
2575
+ .unwrap_or(false)
2576
+ }
2577
+
2578
+ /// Basic HTML minification
2579
+ fn minify_html(html: &str) -> String {
2580
+ let mut result = String::with_capacity(html.len());
2581
+ let mut in_raw = false;
2582
+ let mut last_was_space = false;
2583
+
2584
+ for line in html.lines() {
2585
+ let trimmed = line.trim();
2586
+
2587
+ // <pre>, <script> and <style> carry significant whitespace and must
2588
+ // survive minification verbatim: <pre> is literal text, while a JS
2589
+ // `//` line comment or ASI in <script> breaks the moment its trailing
2590
+ // newline is collapsed into a space (the comment swallows the rest of
2591
+ // the script). Keep these blocks line-for-line, exactly as <pre> always
2592
+ // did. The opening-tag line enters the block before we emit it; the
2593
+ // closing-tag line leaves it (and collapses, which only tightens the
2594
+ // bare `</pre>` / `</script>` / `</style>`).
2595
+ if trimmed.contains("<pre") || trimmed.contains("<script") || trimmed.contains("<style") {
2596
+ in_raw = true;
2597
+ }
2598
+ if trimmed.contains("</pre>") || trimmed.contains("</script>") || trimmed.contains("</style>") {
2599
+ in_raw = false;
2600
+ }
2601
+
2602
+ if in_raw {
2603
+ result.push_str(line);
2604
+ result.push('\n');
2605
+ last_was_space = false;
2606
+ } else {
2607
+ // The line break preceding this line is whitespace: collapse it to a
2608
+ // single space so attributes/text split across lines don't glue
2609
+ // together (e.g. a multi-line <meta name=... content=...> tag).
2610
+ // The >\s+< pass below re-tightens genuine tag boundaries.
2611
+ if !last_was_space && !result.is_empty() {
2612
+ result.push(' ');
2613
+ last_was_space = true;
2614
+ }
2615
+ for c in trimmed.chars() {
2616
+ if c.is_whitespace() {
2617
+ if !last_was_space {
2618
+ result.push(' ');
2619
+ last_was_space = true;
2620
+ }
2621
+ } else {
2622
+ result.push(c);
2623
+ last_was_space = false;
2624
+ }
2625
+ }
2626
+ }
2627
+ }
2628
+
2629
+ // Collapse whitespace between tags to a SINGLE space — never delete it.
2630
+ // Inter-element whitespace is significant for inline content: the browser
2631
+ // (and the non-compiled runtime) render `<em>a</em> <em>b</em>` with a space,
2632
+ // so dropping it (`><`) glued words onto preceding inline elements (the
2633
+ // status-chip "💠Concussion" parity bug). One space matches both the browser's
2634
+ // own collapsing and the non-minified/runtime output; where the space is
2635
+ // insignificant (between block/table/list/head elements) the parser discards
2636
+ // it anyway, exactly as it does for the non-minified newline.
2637
+ let result = regex::Regex::new(r">\s+<")
2638
+ .unwrap()
2639
+ .replace_all(&result, "> <")
2640
+ .to_string();
2641
+
2642
+ result.trim().to_string()
2643
+ }
2644
+
2645
+ fn find_bytes(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
2646
+ haystack[from..].windows(needle.len()).position(|w| w == needle).map(|p| p + from)
2647
+ }
2648
+
2649
+ fn find_bytes_ci(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
2650
+ haystack[from..].windows(needle.len()).position(|w| w.eq_ignore_ascii_case(needle)).map(|p| p + from)
2651
+ }
2652
+
2653
+ fn count_newlines(bytes: &[u8]) -> usize {
2654
+ bytes.iter().filter(|&&b| b == b'\n').count()
2655
+ }
2656
+
2657
+ #[cfg(test)]
2658
+ mod tests {
2659
+ use super::*;
2660
+
2661
+ #[test]
2662
+ fn minify_preserves_script_newlines_so_line_comments_dont_swallow_code() {
2663
+ // A `//` line comment inside a component script relies on its trailing
2664
+ // newline. If minify collapses newlines into spaces, the comment eats
2665
+ // the rest of the script → SyntaxError at runtime (new Function).
2666
+ let html = "<page>\n\
2667
+ <script type=\"module\">\n\
2668
+ \x20 const a = 1; // explain a\n\
2669
+ \x20 // keep explaining\n\
2670
+ \x20 const b = 2;\n\
2671
+ </script>\n\
2672
+ </page>";
2673
+
2674
+ let out = minify_html(html);
2675
+
2676
+ // The code after the comments must still be reachable, i.e. on its own
2677
+ // line rather than glued behind the `//`.
2678
+ let script = &out[out.find("<script").unwrap()..out.find("</script>").unwrap()];
2679
+ assert!(
2680
+ script.contains('\n'),
2681
+ "script newlines were collapsed, // comment swallows following code: {script:?}"
2682
+ );
2683
+ assert!(out.contains("const b = 2"), "code after // comment lost: {out:?}");
2684
+
2685
+ // Surrounding HTML must still be minified (tag boundaries tightened).
2686
+ assert!(out.contains("<page><script"), "non-script HTML not minified: {out:?}");
2687
+ }
2688
+
2689
+ #[test]
2690
+ fn minify_preserves_style_newlines() {
2691
+ let html = "<page>\n\
2692
+ <style>\n\
2693
+ \x20 a { color: red; }\n\
2694
+ \x20 b { color: blue; }\n\
2695
+ </style>\n\
2696
+ </page>";
2697
+
2698
+ let out = minify_html(html);
2699
+ let style = &out[out.find("<style").unwrap()..out.find("</style>").unwrap()];
2700
+ assert!(style.contains('\n'), "style newlines collapsed: {style:?}");
2701
+ }
2702
+
2703
+ #[test]
2704
+ fn atomic_write_replaces_content_without_leaving_tmp_files() {
2705
+ let dir = std::env::temp_dir().join("vibe_atomic_write_test");
2706
+ let _ = fs::remove_dir_all(&dir);
2707
+ fs::create_dir_all(&dir).unwrap();
2708
+ let target = dir.join("page.html");
2709
+
2710
+ atomic_write(&target, "first").unwrap();
2711
+ assert_eq!(fs::read_to_string(&target).unwrap(), "first");
2712
+
2713
+ // Overwriting an existing file goes through the same tmp+rename path.
2714
+ atomic_write(&target, "second, longer content").unwrap();
2715
+ assert_eq!(fs::read_to_string(&target).unwrap(), "second, longer content");
2716
+
2717
+ let leftovers: Vec<String> = fs::read_dir(&dir)
2718
+ .unwrap()
2719
+ .filter_map(|e| e.ok())
2720
+ .map(|e| e.file_name().to_string_lossy().into_owned())
2721
+ .filter(|n| n != "page.html")
2722
+ .collect();
2723
+ assert!(leftovers.is_empty(), "temp artifacts left behind: {leftovers:?}");
2724
+ }
2725
+
2726
+ // A minimal on-disk project: source with one page carrying a distinctive
2727
+ // binding, empty components dir, output dir sibling. Returns (config, page
2728
+ // source path, compiled page output path, manifest path).
2729
+ // nodeModulesAsIs copies must skip cache-tagged directories (the Cache
2730
+ // Directory Tagging spec: cargo target/, many build caches). A symlinked
2731
+ // local package dragging its cargo target/ along turned a 15MB copy into
2732
+ // gigabytes.
2733
+ #[test]
2734
+ fn as_is_copy_skips_cachedir_tagged_directories() {
2735
+ let dir = std::env::temp_dir().join("vibe-cachedir-copy-test");
2736
+ let _ = fs::remove_dir_all(&dir);
2737
+ let src = dir.join("node_modules/pkg");
2738
+ fs::create_dir_all(src.join("runtime")).unwrap();
2739
+ fs::create_dir_all(src.join("build/target/release")).unwrap();
2740
+ fs::write(src.join("index.js"), "export default 1;").unwrap();
2741
+ fs::write(src.join("runtime/state.js"), "export const s = 1;").unwrap();
2742
+ fs::write(
2743
+ src.join("build/target/CACHEDIR.TAG"),
2744
+ "Signature: 8a477f597d28d172789f06886806bc55\n",
2745
+ )
2746
+ .unwrap();
2747
+ fs::write(src.join("build/target/release/artifact"), "big").unwrap();
2748
+
2749
+ let dest = dir.join("out/node_modules");
2750
+ copy_dir_recursive(&dir.join("node_modules"), &dest).unwrap();
2751
+
2752
+ assert!(dest.join("pkg/index.js").exists());
2753
+ assert!(dest.join("pkg/runtime/state.js").exists());
2754
+ assert!(dest.join("pkg/build").exists());
2755
+ assert!(!dest.join("pkg/build/target").exists(), "cache-tagged dir was copied");
2756
+
2757
+ let _ = fs::remove_dir_all(&dir);
2758
+ }
2759
+
2760
+ // Watch mode re-runs compile_spa on any pages-tree change: an edited page
2761
+ // re-transforms and the shell recomposes (title/head/route table), a
2762
+ // removed page's fragment is pruned from the output mirror and its route
2763
+ // leaves the table. Full-rerun semantics keep add/edit/remove one path.
2764
+ #[test]
2765
+ fn spa_recompile_syncs_fragments_shell_and_routes() {
2766
+ let dir = std::env::temp_dir().join("vibe-spa-watch-test");
2767
+ let _ = fs::remove_dir_all(&dir);
2768
+ let source = dir.join("src");
2769
+ fs::create_dir_all(source.join("pages")).unwrap();
2770
+ fs::create_dir_all(source.join("components")).unwrap();
2771
+ let page = |title: &str, body: &str| {
2772
+ format!(
2773
+ "<!DOCTYPE html>\n<html><head><title>{}</title></head>\n<body vibe-fouc>{}</body></html>\n",
2774
+ title, body
2775
+ )
2776
+ };
2777
+ fs::write(source.join("pages/index.html"), page("Home", "<h1>home</h1>")).unwrap();
2778
+ fs::write(source.join("pages/about.html"), page("About", "<h1>about</h1>")).unwrap();
2779
+
2780
+ let config = Config {
2781
+ source: source.clone(),
2782
+ output: dir.join("out"),
2783
+ _source_str: String::new(),
2784
+ _output_str: String::new(),
2785
+ components: "components".to_string(),
2786
+ pages: "pages".to_string(),
2787
+ _assets: String::new(),
2788
+ root: None,
2789
+ minify: false,
2790
+ elements_as_is: true,
2791
+ source_maps: false,
2792
+ reserved_elements: Vec::new(),
2793
+ skip_files: Vec::new(),
2794
+ node_modules_as_is: false,
2795
+ components_as_is: true,
2796
+ runtime_as_is: false,
2797
+ iterations_as_is: false,
2798
+ no_clean: false,
2799
+ fouc_as_is: false,
2800
+ spa: true,
2801
+ working_dir: dir.clone(),
2802
+ };
2803
+
2804
+ let mut compiler = Compiler::new(config.clone(), false);
2805
+ let mut parser = HtmlParser::new(config.components_path());
2806
+ parser.load_elements().unwrap();
2807
+ compiler.compile().unwrap();
2808
+
2809
+ let shell_path = config.output.join("index.html");
2810
+ let fragment = config.output.join("components/vibe-spa/about.html");
2811
+ let shell = fs::read_to_string(&shell_path).unwrap();
2812
+ assert!(shell.contains("\"route\": \"/about\""));
2813
+ assert!(shell.contains("<title>Home</title>"));
2814
+ assert!(fragment.exists());
2815
+
2816
+ // Edited page: fragment re-transforms, shell recomposes with the new
2817
+ // harvested title.
2818
+ fs::write(source.join("pages/about.html"), page("Regenerated", "<h1>about v2</h1>")).unwrap();
2819
+ compiler.compile_spa(&parser).unwrap();
2820
+ let shell = fs::read_to_string(&shell_path).unwrap();
2821
+ assert!(shell.contains("\"title\": \"Regenerated\""));
2822
+ assert!(fs::read_to_string(&fragment).unwrap().contains("about v2"));
2823
+
2824
+ // Removed page: route leaves the table, orphan fragment is pruned.
2825
+ fs::remove_file(source.join("pages/about.html")).unwrap();
2826
+ compiler.compile_spa(&parser).unwrap();
2827
+ let shell = fs::read_to_string(&shell_path).unwrap();
2828
+ assert!(!shell.contains("\"route\": \"/about\""));
2829
+ assert!(!fragment.exists());
2830
+
2831
+ let _ = fs::remove_dir_all(&dir);
2832
+ }
2833
+
2834
+ fn manifest_test_project(name: &str) -> (Config, PathBuf, PathBuf, PathBuf) {
2835
+ let dir = std::env::temp_dir().join(format!("vibe_{}_test", name));
2836
+ let _ = fs::remove_dir_all(&dir);
2837
+ let source = dir.join("src");
2838
+ let output = dir.join("out");
2839
+ fs::create_dir_all(source.join("components")).unwrap();
2840
+ fs::write(
2841
+ source.join("index.html"),
2842
+ "<!doctype html>\n<html><head><title>t</title></head>\n\
2843
+ <body vibe>\n<page-home><h1>@[uniqueMarker123]</h1></page-home>\n</body></html>\n",
2844
+ )
2845
+ .unwrap();
2846
+
2847
+ let config = Config {
2848
+ source: source.clone(),
2849
+ output: output.clone(),
2850
+ _source_str: String::new(),
2851
+ _output_str: String::new(),
2852
+ components: "components".to_string(),
2853
+ pages: "pages".to_string(),
2854
+ _assets: String::new(),
2855
+ root: None,
2856
+ minify: false,
2857
+ elements_as_is: false,
2858
+ source_maps: false,
2859
+ reserved_elements: Vec::new(),
2860
+ skip_files: Vec::new(),
2861
+ node_modules_as_is: false,
2862
+ components_as_is: false,
2863
+ runtime_as_is: false,
2864
+ iterations_as_is: false,
2865
+ no_clean: false,
2866
+ fouc_as_is: false,
2867
+ spa: false,
2868
+ working_dir: dir.clone(),
2869
+ };
2870
+
2871
+ let page_src = source.join("index.html");
2872
+ let page_out = output.join("index.html");
2873
+ let manifest = output.join("vibe-hyperspeed").join("index.html.manifest.js");
2874
+ (config, page_src, page_out, manifest)
2875
+ }
2876
+
2877
+ // The full build always mirrors components/ verbatim into the output —
2878
+ // runtime-fetched components (`<component src="@[page.src]">`, iter-prop
2879
+ // roots, components_as_is) are served from that mirror. Watch mode must
2880
+ // keep the same contract: an edited component reaches the mirror even when
2881
+ // NO page inlines it (zero dependents), and a deleted component leaves it.
2882
+ #[test]
2883
+ fn changed_component_is_remirrored_to_output() {
2884
+ let (config, _page_src, _page_out, _manifest) = manifest_test_project("component_mirror");
2885
+ let source_component = config.source.join("components").join("widget.html");
2886
+ let mirrored = config.output.join("components").join("widget.html");
2887
+ let nested_src = config.source.join("components").join("spa").join("pane.html");
2888
+ let nested_out = config.output.join("components").join("spa").join("pane.html");
2889
+ fs::write(&source_component, "<spa-widget>v1</spa-widget>\n").unwrap();
2890
+
2891
+ let mut compiler = Compiler::new(config, false);
2892
+ compiler.compile().expect("compile should succeed");
2893
+ assert_eq!(
2894
+ fs::read_to_string(&mirrored).unwrap(),
2895
+ "<spa-widget>v1</spa-widget>\n",
2896
+ "sanity: full build mirrors the component"
2897
+ );
2898
+
2899
+ // Edit the component — no page references it, so the dependency graph
2900
+ // maps it to zero pages; the mirror must still track the change.
2901
+ fs::write(&source_component, "<spa-widget>v2</spa-widget>\n").unwrap();
2902
+ let copied = compiler
2903
+ .mirror_component_files(&[source_component.clone()])
2904
+ .expect("mirroring should succeed");
2905
+ assert_eq!(copied, 1);
2906
+ assert_eq!(
2907
+ fs::read_to_string(&mirrored).unwrap(),
2908
+ "<spa-widget>v2</spa-widget>\n",
2909
+ "edited component did not reach the output mirror"
2910
+ );
2911
+
2912
+ // A component created mid-session (parent dirs may not exist yet).
2913
+ fs::create_dir_all(nested_src.parent().unwrap()).unwrap();
2914
+ fs::write(&nested_src, "<spa-pane>new</spa-pane>\n").unwrap();
2915
+ compiler
2916
+ .mirror_component_files(&[nested_src.clone()])
2917
+ .expect("mirroring a new nested component should succeed");
2918
+ assert_eq!(fs::read_to_string(&nested_out).unwrap(), "<spa-pane>new</spa-pane>\n");
2919
+
2920
+ // Deleting the source removes the mirrored copy.
2921
+ fs::remove_file(&source_component).unwrap();
2922
+ compiler
2923
+ .mirror_component_files(&[source_component])
2924
+ .expect("mirroring a deletion should succeed");
2925
+ assert!(!mirrored.exists(), "deleted component still present in the output mirror");
2926
+ }
2927
+
2928
+ // The watcher race: another writer truncates/rewrites a compiled page on
2929
+ // disk between our compile and our manifest pass. The manifest must be
2930
+ // built from the HTML this compiler just produced in memory — never from a
2931
+ // disk read-back — or a torn read yields a valid-but-empty manifest and the
2932
+ // page hydrates to a blank screen.
2933
+ #[test]
2934
+ fn manifest_survives_output_corruption_between_compile_and_manifests() {
2935
+ let (config, _page_src, page_out, manifest) =
2936
+ manifest_test_project("manifest_memory_full");
2937
+
2938
+ let mut compiler = Compiler::new(config, false);
2939
+ compiler.compile().expect("compile should succeed");
2940
+ assert!(
2941
+ fs::read_to_string(&page_out).unwrap().contains("uniqueMarker123"),
2942
+ "sanity: compiled page carries the binding"
2943
+ );
2944
+
2945
+ // Simulate the concurrent writer: the on-disk page is now a shell.
2946
+ fs::write(&page_out, "<!doctype html>\n<html><head></head><body></body></html>\n").unwrap();
2947
+
2948
+ compiler.generate_manifests().expect("manifest generation should succeed");
2949
+
2950
+ let manifest_js = fs::read_to_string(&manifest).expect("manifest should exist");
2951
+ assert!(
2952
+ manifest_js.contains("uniqueMarker123"),
2953
+ "manifest was built from the corrupted disk file instead of the in-memory compile output"
2954
+ );
2955
+ }
2956
+
2957
+ // Same property on the incremental watch path (generate_manifests_for_files),
2958
+ // which is where the two-watcher race actually corrupted manifests.
2959
+ #[test]
2960
+ fn incremental_manifest_survives_output_corruption() {
2961
+ let (config, page_src, page_out, manifest) =
2962
+ manifest_test_project("manifest_memory_incremental");
2963
+
2964
+ let mut compiler = Compiler::new(config.clone(), false);
2965
+ let mut parser = HtmlParser::new(config.components_path());
2966
+ parser.load_elements().unwrap();
2967
+
2968
+ fs::create_dir_all(&config.output).unwrap();
2969
+ compiler
2970
+ .compile_specific_html_files(&[page_src.clone()], &parser)
2971
+ .expect("incremental compile should succeed");
2972
+
2973
+ fs::write(&page_out, "<!doctype html>\n<html><head></head><body></body></html>\n").unwrap();
2974
+
2975
+ compiler
2976
+ .generate_manifests_for_files(&[page_src])
2977
+ .expect("incremental manifest generation should succeed");
2978
+
2979
+ let manifest_js = fs::read_to_string(&manifest).expect("manifest should exist");
2980
+ assert!(
2981
+ manifest_js.contains("uniqueMarker123"),
2982
+ "incremental manifest was built from the corrupted disk file instead of the in-memory compile output"
2983
+ );
2984
+ }
2985
+ }