@ape-egg/vibe 2.3.0 → 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.
Files changed (57) hide show
  1. package/README.md +14 -4
  2. package/boot.js +4 -4
  3. package/component.js +27 -29
  4. package/hot-module-refresh.js +4 -4
  5. package/index.js +10 -15
  6. package/llms.txt +8 -6
  7. package/package.json +19 -14
  8. package/runtime/affected.js +159 -36
  9. package/runtime/cleanup.js +45 -1
  10. package/runtime/component.js +312 -99
  11. package/runtime/conditionals.js +111 -14
  12. package/runtime/debug.js +24 -0
  13. package/runtime/dispatch.js +172 -0
  14. package/runtime/hydrate.js +251 -111
  15. package/runtime/index.js +180 -71
  16. package/runtime/iterate.js +125 -50
  17. package/runtime/iteration-utils.js +59 -8
  18. package/runtime/manifest.js +77 -2
  19. package/runtime/parse.js +69 -5
  20. package/runtime/pre-compiled-iterations.js +19 -6
  21. package/runtime/pre-compiled-manifest.js +13 -4
  22. package/runtime/staging.js +153 -0
  23. package/runtime/state.js +31 -0
  24. package/runtime/tracking.js +173 -0
  25. package/runtime/utils.js +155 -78
  26. package/spa.js +77 -14
  27. package/vibe.css +8 -4
  28. package/CHANGELOG.md +0 -1196
  29. package/ROADMAP.md +0 -397
  30. package/compiler/bin/vibe-compile.js +0 -121
  31. package/compiler/native/.gitkeep +0 -0
  32. package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
  33. package/compiler/native/vibe-compiler-linux-x64 +0 -0
  34. package/compiler/src/Cargo.lock +0 -2023
  35. package/compiler/src/Cargo.toml +0 -38
  36. package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +0 -241
  37. package/compiler/src/compiler/binding_case.rs +0 -88
  38. package/compiler/src/compiler/compile.rs +0 -2880
  39. package/compiler/src/compiler/component_tagger.rs +0 -469
  40. package/compiler/src/compiler/iteration_optimizer.rs +0 -455
  41. package/compiler/src/compiler/js_analyzer.rs +0 -715
  42. package/compiler/src/compiler/manifest_builder.rs +0 -693
  43. package/compiler/src/compiler/mod.rs +0 -16
  44. package/compiler/src/compiler/name_binding_protect.rs +0 -207
  45. package/compiler/src/compiler/reassignment_analyzer.rs +0 -456
  46. package/compiler/src/compiler/spa.rs +0 -477
  47. package/compiler/src/compiler/state_extractor.rs +0 -263
  48. package/compiler/src/compiler/value_stamper.rs +0 -921
  49. package/compiler/src/compiler/watcher.rs +0 -1278
  50. package/compiler/src/config.rs +0 -279
  51. package/compiler/src/main.rs +0 -358
  52. package/compiler/src/parser/element.rs +0 -96
  53. package/compiler/src/parser/html.rs +0 -1004
  54. package/compiler/src/parser/mod.rs +0 -8
  55. package/runtime/pre-compiled-manifest.test.mjs +0 -58
  56. package/runtime/scope.js +0 -50
  57. package/test-results/.last-run.json +0 -4
@@ -1,2880 +0,0 @@
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
- // The components directory is always mirrored to the output. With
1348
- // components_as_is every component is loaded at runtime; otherwise
1349
- // most are inlined at build time, but iter-prop each-root components
1350
- // are deliberately left as runtime `<component src>` tags (see
1351
- // is_iter_prop_root in parser/html.rs) and the runtime fetches their
1352
- // source from here — exactly as it does in non-compiled mode.
1353
- if file_name == self.config.components {
1354
- let new_relative = if relative_path.is_empty() {
1355
- file_name.to_string()
1356
- } else {
1357
- format!("{}/{}", relative_path, file_name)
1358
- };
1359
- self.copy_directory(&path, &new_relative, canonical_source, stats)?;
1360
- // Skip further processing (don't recurse into components)
1361
- continue;
1362
- }
1363
-
1364
- // Recurse into subdirectory
1365
- let new_relative = if relative_path.is_empty() {
1366
- file_name.to_string()
1367
- } else {
1368
- format!("{}/{}", relative_path, file_name)
1369
- };
1370
-
1371
- self.process_directory_assets_only(&path, &new_relative, canonical_output, canonical_source, stats)?;
1372
- } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
1373
- // Skip files matching skip patterns
1374
- if should_skip_path(&path, file_name, &self.config.skip_files) {
1375
- continue;
1376
- }
1377
-
1378
- // Only copy non-HTML assets
1379
- if ext != "html" && MIRROR_EXTENSIONS.contains(&ext) {
1380
- self.copy_file(&path, relative_path)?;
1381
- stats.files_copied += 1;
1382
- if let Some(ref mut logger) = self.logger {
1383
- logger.log(&path, FileOperation::Copied, canonical_source);
1384
- }
1385
- }
1386
- }
1387
- }
1388
-
1389
- Ok(())
1390
- }
1391
-
1392
- fn compile_html_file(
1393
- &mut self,
1394
- path: &Path,
1395
- parser: &HtmlParser,
1396
- relative_path: &str,
1397
- ) -> Result<(usize, usize, Vec<String>), CompileError> {
1398
- let content = fs::read_to_string(path).map_err(|e| CompileError::ReadError {
1399
- path: path.display().to_string(),
1400
- source: e,
1401
- })?;
1402
-
1403
- // Count <component src="..."> instances before processing (only if not components_as_is)
1404
- let (internal_count, external_count, component_srcs) = if !self.config.components_as_is {
1405
- self.extract_components(&content)
1406
- } else {
1407
- (0, 0, Vec::new())
1408
- };
1409
-
1410
- // Compile: transform custom tags to <component>, inline if needed, transform custom elements
1411
- let processed = parser.process_html_with_cache(
1412
- &content,
1413
- self.config.elements_as_is,
1414
- &self.config.reserved_elements,
1415
- self.config.components_as_is,
1416
- &self.config.components,
1417
- &self.component_cache,
1418
- );
1419
-
1420
- // Minify if requested
1421
- let mut output = if self.config.minify {
1422
- minify_html(&processed)
1423
- } else {
1424
- processed
1425
- };
1426
-
1427
- // Remove FOUC prevention unless fouc_as_is is enabled
1428
- if !self.config.fouc_as_is {
1429
- output = remove_fouc_prevention(output);
1430
- }
1431
-
1432
- // Write to output
1433
- let output_path = self.get_output_path(path, relative_path)?;
1434
- atomic_write(&output_path, &output).map_err(|e| CompileError::WriteError {
1435
- path: output_path.display().to_string(),
1436
- source: e,
1437
- })?;
1438
-
1439
- // Keep the exact bytes for this pass's manifest generation.
1440
- self.compiled_html.insert(output_path, output);
1441
-
1442
- // Return component counts and src list
1443
- Ok((internal_count, external_count, component_srcs))
1444
- }
1445
-
1446
- /// True when a canonicalized directory is the configured pages tree.
1447
- fn is_spa_pages_dir(&self, canonical_path: &Path) -> bool {
1448
- self.config
1449
- .source
1450
- .join(&self.config.pages)
1451
- .canonicalize()
1452
- .map(|pages| pages == *canonical_path)
1453
- .unwrap_or(false)
1454
- }
1455
-
1456
- fn collect_page_files(dir: &Path, skip: &[String], files: &mut Vec<PathBuf>) {
1457
- let Ok(entries) = fs::read_dir(dir) else { return };
1458
- for entry in entries.flatten() {
1459
- let path = entry.path();
1460
- let name = entry.file_name();
1461
- let name = name.to_string_lossy();
1462
- if should_skip_path(&path, &name, skip) {
1463
- continue;
1464
- }
1465
- if path.is_dir() {
1466
- Self::collect_page_files(&path, skip, files);
1467
- } else if path.extension().and_then(|e| e.to_str()) == Some("html") {
1468
- files.push(path);
1469
- }
1470
- }
1471
- }
1472
-
1473
- /// SPA mode (fetched): transform every page into a fragment under
1474
- /// output/components/vibe-spa/, then compose the shell at output root.
1475
- /// Per-page stamped HTML, per-page manifests, and the pages/ output dir
1476
- /// do not exist in SPA mode — fragments are runtime-parsed on mount; the
1477
- /// shell flows through the normal manifest pipeline via compiled_html.
1478
- /// Returns the number of files written (pages + shell) for stats.
1479
- ///
1480
- /// Watch mode re-runs this whole pass on any pages-tree change — the
1481
- /// shell depends on every page's head/title/body attrs and the pass is
1482
- /// a few milliseconds, so per-page incrementality would buy nothing.
1483
- pub(crate) fn compile_spa(&mut self, parser: &HtmlParser) -> Result<usize, CompileError> {
1484
- use crate::compiler::spa;
1485
-
1486
- let pages_dir = self.config.source.join(&self.config.pages);
1487
- if !pages_dir.exists() {
1488
- return Err(CompileError::SpaError(format!(
1489
- "needs a pages directory at {}",
1490
- pages_dir.display()
1491
- )));
1492
- }
1493
- let reserved = self.config.components_path().join("vibe-spa");
1494
- if reserved.exists() {
1495
- return Err(CompileError::SpaError(format!(
1496
- "{} is reserved for compiled page fragments — move or rename it",
1497
- reserved.display()
1498
- )));
1499
- }
1500
-
1501
- let mut page_files = Vec::new();
1502
- Self::collect_page_files(&pages_dir, &self.config.skip_files, &mut page_files);
1503
- page_files.sort();
1504
- if page_files.is_empty() {
1505
- return Err(CompileError::SpaError(format!(
1506
- "found no pages under {}",
1507
- pages_dir.display()
1508
- )));
1509
- }
1510
-
1511
- let mut pages = Vec::new();
1512
- for path in &page_files {
1513
- let rel = path
1514
- .strip_prefix(&pages_dir)
1515
- .unwrap()
1516
- .to_string_lossy()
1517
- .replace('\\', "/");
1518
- let content = fs::read_to_string(path).map_err(|e| CompileError::ReadError {
1519
- path: path.display().to_string(),
1520
- source: e,
1521
- })?;
1522
- let processed = parser.process_html_with_cache(
1523
- &content,
1524
- self.config.elements_as_is,
1525
- &self.config.reserved_elements,
1526
- self.config.components_as_is,
1527
- &self.config.components,
1528
- &self.component_cache,
1529
- );
1530
- // Build-inlined children get the compiled-document treatment:
1531
- // tagged wrapper ids + this.→_cN rewrites, matching their
1532
- // vibe-module scripts (the runtime's mounted-subtree pass claims
1533
- // ids via closest wrapper). Same protect→tag order as the
1534
- // manifest pipeline; the extracted state is a page concern and
1535
- // fragments have none to stamp.
1536
- let protected = crate::compiler::name_binding_protect::protect(&processed);
1537
- let tagged =
1538
- crate::compiler::component_tagger::ComponentTagger::tag_components(
1539
- &protected,
1540
- &self.config.source,
1541
- )
1542
- .map_err(CompileError::SpaError)?;
1543
- let page = spa::dissect_page(&rel, &tagged.html).map_err(CompileError::SpaError)?;
1544
-
1545
- if let Some(api) = page.hygiene_offender {
1546
- eprintln!(
1547
- "{}: pages/{} starts side effects ({}) without an $.on('unmount') teardown — under SPA these outlive navigation",
1548
- "Warning".yellow(),
1549
- rel,
1550
- api
1551
- );
1552
- }
1553
-
1554
- let fragment_path = self
1555
- .config
1556
- .output
1557
- .join("components")
1558
- .join("vibe-spa")
1559
- .join(&rel);
1560
- if let Some(parent) = fragment_path.parent() {
1561
- fs::create_dir_all(parent).map_err(|_| {
1562
- CompileError::CreateDirError(parent.display().to_string())
1563
- })?;
1564
- }
1565
- atomic_write(&fragment_path, &page.fragment).map_err(|e| CompileError::WriteError {
1566
- path: fragment_path.display().to_string(),
1567
- source: e,
1568
- })?;
1569
-
1570
- pages.push(page);
1571
- }
1572
-
1573
- // The fragments dir mirrors the pages tree: prune fragments whose
1574
- // page no longer exists (watch-mode page removals, no-clean runs).
1575
- let fragments_root = self.config.output.join("components").join("vibe-spa");
1576
- let live: HashSet<String> = pages.iter().map(|p| p.rel_path.clone()).collect();
1577
- let mut existing = Vec::new();
1578
- Self::collect_page_files(&fragments_root, &self.config.skip_files, &mut existing);
1579
- for stale in existing {
1580
- let rel = stale
1581
- .strip_prefix(&fragments_root)
1582
- .unwrap()
1583
- .to_string_lossy()
1584
- .replace('\\', "/");
1585
- if !live.contains(&rel) {
1586
- let _ = fs::remove_file(&stale);
1587
- }
1588
- }
1589
-
1590
- let order = spa::route_order(&pages);
1591
- let shell = spa::compose_shell(&pages, &order);
1592
- if self.verbose {
1593
- for note in &shell.notes {
1594
- println!(" spa: {}", note);
1595
- }
1596
- }
1597
-
1598
- if self.config.source.join("index.html").exists() {
1599
- eprintln!(
1600
- "{}: SPA shell overwrites the compiled root index.html (source has its own index.html outside the pages tree)",
1601
- "Warning".yellow()
1602
- );
1603
- }
1604
-
1605
- let shell_html = if self.config.minify {
1606
- minify_html(&shell.html)
1607
- } else {
1608
- shell.html
1609
- };
1610
- let shell_path = self.config.output.join("index.html");
1611
- atomic_write(&shell_path, &shell_html).map_err(|e| CompileError::WriteError {
1612
- path: shell_path.display().to_string(),
1613
- source: e,
1614
- })?;
1615
- // The shell rides the normal manifest pipeline like any page.
1616
- self.compiled_html.insert(shell_path, shell_html);
1617
-
1618
- Ok(pages.len() + 1)
1619
- }
1620
-
1621
- /// Regenerate the shell's manifest after a watch-mode recompose (the full
1622
- /// build gets it via generate_manifests; incremental recompiles target
1623
- /// only changed files, and the shell has no source counterpart to list).
1624
- /// Manifest yes, stamp no — same rule as the full pipeline.
1625
- pub(crate) fn generate_shell_manifest(&self) -> Result<(), CompileError> {
1626
- let shell_path = self.config.output.join("index.html");
1627
- let disk_html;
1628
- let html: &str = match self.compiled_html.get(&shell_path) {
1629
- Some(html) => html,
1630
- None => {
1631
- disk_html = fs::read_to_string(&shell_path).map_err(|e| CompileError::ReadError {
1632
- path: shell_path.display().to_string(),
1633
- source: e,
1634
- })?;
1635
- &disk_html
1636
- }
1637
- };
1638
- Self::generate_file_manifest(
1639
- html,
1640
- &shell_path,
1641
- &self.config.output,
1642
- "index.html",
1643
- self.verbose,
1644
- self.config.iterations_as_is,
1645
- self.config.components_as_is,
1646
- &self.config.source,
1647
- self.config.root.as_deref(),
1648
- &self.compute_global_constants(),
1649
- false,
1650
- )
1651
- .map_err(CompileError::SpaError)
1652
- }
1653
-
1654
- fn extract_components(&self, content: &str) -> (usize, usize, Vec<String>) {
1655
- let mut internal = 0;
1656
- let mut external = 0;
1657
- let mut srcs = Vec::new();
1658
-
1659
- // Simple regex-based extraction of <component src="..."> tags
1660
- let re = Regex::new(r#"<component[^>]+src\s*=\s*["']([^"']+)["']"#).unwrap();
1661
- for cap in re.captures_iter(content) {
1662
- if let Some(src) = cap.get(1) {
1663
- let src_str = src.as_str().to_string();
1664
- if src_str.starts_with("http://") || src_str.starts_with("https://") {
1665
- external += 1;
1666
- } else {
1667
- internal += 1;
1668
- }
1669
- srcs.push(src_str);
1670
- }
1671
- }
1672
-
1673
- (internal, external, srcs)
1674
- }
1675
-
1676
- fn build_component_relationships(&mut self, component_srcs: &[String]) -> Vec<(String, Vec<String>)> {
1677
- let mut relationships = Vec::new();
1678
- let mut visited = HashSet::new();
1679
-
1680
- for src in component_srcs {
1681
- self.collect_component_children(src, &mut relationships, &mut visited);
1682
- }
1683
-
1684
- relationships
1685
- }
1686
-
1687
- fn collect_component_children(&mut self, component_src: &str, relationships: &mut Vec<(String, Vec<String>)>, visited: &mut HashSet<String>) {
1688
- if visited.contains(component_src) {
1689
- return;
1690
- }
1691
- visited.insert(component_src.to_string());
1692
-
1693
- // Read component content
1694
- let content = if component_src.starts_with("http://") || component_src.starts_with("https://") {
1695
- match self.fetch_external_component_raw(component_src) {
1696
- Ok(c) => c,
1697
- Err(_) => return,
1698
- }
1699
- } else {
1700
- match self.resolve_component_path(component_src) {
1701
- Ok(path) => match fs::read_to_string(&path) {
1702
- Ok(c) => c,
1703
- Err(_) => return,
1704
- },
1705
- Err(_) => return,
1706
- }
1707
- };
1708
-
1709
- // Extract immediate children
1710
- let children = self.extract_component_srcs(&content);
1711
-
1712
- // Add this component and its children to relationships
1713
- relationships.push((component_src.to_string(), children.clone()));
1714
-
1715
- // Recursively collect children's relationships
1716
- for child in children {
1717
- self.collect_component_children(&child, relationships, visited);
1718
- }
1719
- }
1720
-
1721
-
1722
- /// MIRROR_MODE: Copy file to output, preserving relative path
1723
- fn copy_file(&self, path: &Path, relative_path: &str) -> Result<(), CompileError> {
1724
- let output_path = self.get_output_path(path, relative_path)?;
1725
-
1726
- fs::copy(path, &output_path).map_err(|e| CompileError::WriteError {
1727
- path: output_path.display().to_string(),
1728
- source: e,
1729
- })?;
1730
-
1731
- Ok(())
1732
- }
1733
-
1734
- /// Re-mirror specific source files under the components directory into the
1735
- /// output. The full build always mirrors components/ verbatim (see
1736
- /// process_directory_assets_only): runtime-fetched components —
1737
- /// `<component src="@[page.src]">` targets, iter-prop each-roots,
1738
- /// components_as_is — are served from that mirror at request time. Watch
1739
- /// mode calls this for every changed component so the mirror tracks edits
1740
- /// and deletions even when no page inlines the component (zero graph
1741
- /// dependents). Writes are atomic (same tmp+rename as compiled pages) so a
1742
- /// runtime fetch mid-copy never sees a torn file. Returns how many files
1743
- /// were copied.
1744
- pub fn mirror_component_files(&self, files: &[PathBuf]) -> Result<usize, CompileError> {
1745
- let mut copied = 0;
1746
- for path in files {
1747
- let relative = path.strip_prefix(&self.config.source).unwrap_or(path);
1748
- let output_path = self.config.output.join(relative);
1749
- if path.exists() {
1750
- if let Some(parent) = output_path.parent() {
1751
- fs::create_dir_all(parent).map_err(|e| CompileError::WriteError {
1752
- path: parent.display().to_string(),
1753
- source: e,
1754
- })?;
1755
- }
1756
- let contents = fs::read_to_string(path).map_err(|e| CompileError::ReadError {
1757
- path: path.display().to_string(),
1758
- source: e,
1759
- })?;
1760
- atomic_write(&output_path, &contents).map_err(|e| CompileError::WriteError {
1761
- path: output_path.display().to_string(),
1762
- source: e,
1763
- })?;
1764
- copied += 1;
1765
- } else if output_path.exists() {
1766
- fs::remove_file(&output_path).map_err(|e| CompileError::WriteError {
1767
- path: output_path.display().to_string(),
1768
- source: e,
1769
- })?;
1770
- }
1771
- }
1772
- Ok(copied)
1773
- }
1774
-
1775
- /// Fetch components only for specific files (used in incremental compilation)
1776
- fn fetch_components_for_files(&mut self, files: &[PathBuf], parser: &HtmlParser) -> Result<(), CompileError> {
1777
- for html_file in files {
1778
- let content = match fs::read_to_string(html_file) {
1779
- Ok(c) => c,
1780
- Err(e) => {
1781
- if self.verbose {
1782
- eprintln!(" Warning: Failed to read {}: {}", html_file.display(), e);
1783
- }
1784
- continue;
1785
- }
1786
- };
1787
-
1788
- // Transform custom tags to <component> tags
1789
- let transformed = parser.process_html(
1790
- &content,
1791
- self.config.elements_as_is,
1792
- &self.config.reserved_elements,
1793
- true, // components_as_is (don't inline, just transform)
1794
- &self.config.components,
1795
- );
1796
-
1797
- // Extract and fetch components recursively
1798
- let components = self.extract_component_srcs(&transformed);
1799
- for component_src in components {
1800
- let mut visiting = std::collections::HashSet::new();
1801
- self.fetch_component_recursive(&component_src, parser, &mut visiting);
1802
- }
1803
- }
1804
-
1805
- Ok(())
1806
- }
1807
-
1808
- /// Fetch and cache all components for inlining (separate from validation)
1809
- /// This recursively fetches all components (external and internal) and populates component_cache
1810
- fn fetch_all_components(&mut self, parser: &HtmlParser) -> Result<(), CompileError> {
1811
- // Find all HTML files in source directory (need to scan pages to find external component references)
1812
- let html_files = self.find_all_html_files(&self.config.source)?;
1813
-
1814
- // Scan each HTML file for components
1815
- for html_file in html_files {
1816
- let content = match fs::read_to_string(&html_file) {
1817
- Ok(c) => c,
1818
- Err(e) => {
1819
- if self.verbose {
1820
- eprintln!(" Warning: Failed to read {}: {}", html_file.display(), e);
1821
- }
1822
- continue;
1823
- }
1824
- };
1825
-
1826
- // Transform custom tags to <component> tags
1827
- let transformed = parser.process_html(
1828
- &content,
1829
- self.config.elements_as_is,
1830
- &self.config.reserved_elements,
1831
- true, // components_as_is (don't inline, just transform)
1832
- &self.config.components,
1833
- );
1834
-
1835
- // Extract and fetch components recursively
1836
- let components = self.extract_component_srcs(&transformed);
1837
- for component_src in components {
1838
- let mut visiting = std::collections::HashSet::new();
1839
- self.fetch_component_recursive(&component_src, parser, &mut visiting);
1840
- }
1841
- }
1842
-
1843
- Ok(())
1844
- }
1845
-
1846
- /// Recursively fetch a component and its nested components (for inlining).
1847
- /// Returns the fully inlined content (all nested components resolved).
1848
- ///
1849
- /// `visiting` tracks the chain of components currently being fetched. A
1850
- /// `<component src>` that points back into the chain is a cycle (direct
1851
- /// self-reference, or A→B→A) — those are valid runtime patterns (recursive
1852
- /// tree components bounded by data depth) but would expand infinitely at
1853
- /// compile time. Cycle references are escaped to `data-vibe-recursive-src`
1854
- /// in the cached content so the inliner's `src=` regex won't keep matching
1855
- /// them; `process_html_with_cache` restores the attribute name at the end
1856
- /// of compilation so the runtime sees a normal `<component src>` tag.
1857
- fn fetch_component_recursive(
1858
- &mut self,
1859
- component_src: &str,
1860
- parser: &HtmlParser,
1861
- visiting: &mut std::collections::HashSet<String>,
1862
- ) -> Option<String> {
1863
- // Normalize path: ensure it starts with / (unless it's a URL)
1864
- let normalized_src = if component_src.starts_with("http://") || component_src.starts_with("https://") {
1865
- component_src.to_string()
1866
- } else {
1867
- let without_prefix = component_src.trim_start_matches("./");
1868
- if without_prefix.starts_with('/') {
1869
- without_prefix.to_string()
1870
- } else {
1871
- format!("/{}", without_prefix)
1872
- }
1873
- };
1874
-
1875
- // Return cached if already fetched and fully resolved
1876
- if let Some(cached) = self.component_cache.get(&normalized_src) {
1877
- return Some(cached.clone());
1878
- }
1879
-
1880
- // Cycle: this component is already higher in the fetch chain. Don't
1881
- // recurse — the caller will leave the `<component src>` tag as-is and
1882
- // the runtime handles the recursion with real data bounds.
1883
- if visiting.contains(&normalized_src) {
1884
- return None;
1885
- }
1886
-
1887
- // Fetch external or read internal component (raw content)
1888
- let content = if normalized_src.starts_with("http://") || normalized_src.starts_with("https://") {
1889
- match self.fetch_external_component_raw(&normalized_src) {
1890
- Ok(c) => c,
1891
- Err(e) => {
1892
- if self.verbose {
1893
- eprintln!(" Warning: Failed to fetch {}: {}", normalized_src, e);
1894
- }
1895
- return None;
1896
- }
1897
- }
1898
- } else {
1899
- match self.resolve_component_path(&normalized_src) {
1900
- Ok(path) => match fs::read_to_string(&path) {
1901
- Ok(c) => c,
1902
- Err(e) => {
1903
- if self.verbose {
1904
- eprintln!(" Warning: Failed to read component {}: {}", normalized_src, e);
1905
- }
1906
- return None;
1907
- }
1908
- },
1909
- Err(e) => {
1910
- if self.verbose {
1911
- eprintln!(" Warning: {}", e);
1912
- }
1913
- return None;
1914
- }
1915
- }
1916
- };
1917
-
1918
- // Transform custom tags in component (but keep component tags as-is for now)
1919
- let mut transformed = parser.process_html(
1920
- &content,
1921
- self.config.elements_as_is, // respect global config
1922
- &self.config.reserved_elements.clone(), // respect global config
1923
- true, // components_as_is = true (keep <component> tags for now so we can recursively resolve them)
1924
- &self.config.components,
1925
- );
1926
-
1927
- // Mark this component as in-flight before recursing into its nested
1928
- // refs. Cycles (direct self-reference or A→B→A chains) are detected
1929
- // by the visiting check at the top of this function.
1930
- visiting.insert(normalized_src.clone());
1931
-
1932
- // Recursively fetch and inline all nested components
1933
- let nested = self.extract_component_srcs(&transformed);
1934
- for nested_src in nested {
1935
- if let Some(nested_content) = self.fetch_component_recursive(&nested_src, parser, visiting) {
1936
- // Inline this nested component into the current component
1937
- transformed = parser.inline_single_component(&transformed, &nested_src, &nested_content);
1938
- }
1939
- }
1940
-
1941
- visiting.remove(&normalized_src);
1942
-
1943
- // Any `<component src>` still in `transformed` whose target is in the
1944
- // visiting chain (a cycle) didn't get inlined. Escape its `src=` so
1945
- // `inline_component_elements` won't keep re-expanding it forever when
1946
- // a page inlines this cached content. `process_html_with_cache`
1947
- // restores the attribute to plain `src=` at the end of compilation so
1948
- // the runtime fetches it normally.
1949
- for ancestor in visiting.iter() {
1950
- transformed = escape_recursive_src(&transformed, ancestor);
1951
- }
1952
- // Also escape direct self-references — a component pointing at itself
1953
- // never made it into `visiting` (the cycle check short-circuited
1954
- // before insertion), so handle it explicitly.
1955
- transformed = escape_recursive_src(&transformed, &normalized_src);
1956
-
1957
- // Cache the fully resolved content
1958
- self.component_cache.insert(normalized_src.clone(), transformed.clone());
1959
-
1960
- Some(transformed)
1961
- }
1962
-
1963
- /// Validate that component filenames don't match reserved elements
1964
- fn validate_component_names(&self) -> Result<(), CompileError> {
1965
- let components_dir = self.config.components_path();
1966
-
1967
- if !components_dir.exists() {
1968
- return Ok(()); // No components directory
1969
- }
1970
-
1971
- let component_files = self.find_all_html_files(&components_dir)?;
1972
-
1973
- for component_file in component_files {
1974
- let file_name = component_file
1975
- .file_stem()
1976
- .and_then(|s| s.to_str())
1977
- .unwrap_or("");
1978
-
1979
- // Check if filename (case-sensitive) matches any reserved element
1980
- if self.config.reserved_elements.contains(&file_name.to_string()) {
1981
- let relative_path = component_file
1982
- .strip_prefix(&self.config.source)
1983
- .unwrap_or(&component_file);
1984
-
1985
- return Err(CompileError::ReservedComponentName {
1986
- component_name: file_name.to_string(),
1987
- file_path: relative_path.display().to_string(),
1988
- });
1989
- }
1990
- }
1991
-
1992
- Ok(())
1993
- }
1994
-
1995
- /// Validate HTML syntax for all files (controlled by validate flag)
1996
- fn validate_html_syntax(&self) -> Result<(), CompileError> {
1997
- // Find all HTML files in source directory
1998
- let html_files = self.find_all_html_files(&self.config.source)?;
1999
-
2000
- for html_file in html_files {
2001
- let content = fs::read_to_string(&html_file).map_err(|e| CompileError::ReadError {
2002
- path: html_file.display().to_string(),
2003
- source: e,
2004
- })?;
2005
-
2006
- // Validate HTML syntax
2007
- self.validate_html(&content, &html_file)?;
2008
- }
2009
-
2010
- Ok(())
2011
- }
2012
-
2013
- /// Fetch external component without caching (returns raw content)
2014
- fn fetch_external_component_raw(&self, url: &str) -> Result<String, String> {
2015
- match ureq::get(url).call() {
2016
- Ok(response) => response.into_string().map_err(|e| format!("Failed to read response body: {}", e)),
2017
- Err(ureq::Error::Status(code, response)) => Err(format!("HTTP {} - {}", code, response.status_text().to_string())),
2018
- Err(e) => Err(format!("Failed to fetch: {}", e)),
2019
- }
2020
- }
2021
-
2022
- fn resolve_component_path(&self, component_src: &str) -> Result<PathBuf, String> {
2023
- // Normalize path (remove leading ./ or /)
2024
- let normalized = component_src.trim_start_matches("./").trim_start_matches('/');
2025
-
2026
- // Try relative to source root
2027
- let path = self.config.source.join(normalized);
2028
- if path.exists() {
2029
- return Ok(path);
2030
- }
2031
-
2032
- Err(format!("Component file not found: {}", component_src))
2033
- }
2034
-
2035
- fn extract_component_srcs(&self, content: &str) -> Vec<String> {
2036
- let mut srcs = Vec::new();
2037
- // Match both <component src="..."> and <div class="component" src="...">
2038
- // After elements_as_is=false transform, <component> becomes <div class="component">
2039
- let re = Regex::new(r#"(?:<component|<div\s+class="component")[^>]+src\s*=\s*["']([^"']+)["']"#).unwrap();
2040
- for cap in re.captures_iter(content) {
2041
- if let Some(src) = cap.get(1) {
2042
- srcs.push(src.as_str().to_string());
2043
- }
2044
- }
2045
- srcs
2046
- }
2047
-
2048
- fn find_all_html_files(&self, dir: &Path) -> Result<Vec<PathBuf>, CompileError> {
2049
- let mut html_files = Vec::new();
2050
- // Get output directory basename to skip nested directories with same name
2051
- let output_dir_name = self.config.output.file_name()
2052
- .and_then(|n| n.to_str())
2053
- .unwrap_or("");
2054
- self.find_html_files_recursive(dir, &mut html_files, output_dir_name)?;
2055
- Ok(html_files)
2056
- }
2057
-
2058
- fn find_html_files_recursive(&self, dir: &Path, files: &mut Vec<PathBuf>, output_dir_name: &str) -> Result<(), CompileError> {
2059
- if !dir.is_dir() {
2060
- return Ok(());
2061
- }
2062
-
2063
- let entries = match fs::read_dir(dir) {
2064
- Ok(e) => e,
2065
- Err(_) => return Ok(()), // Skip directories we can't read
2066
- };
2067
-
2068
- for entry in entries {
2069
- let entry = match entry {
2070
- Ok(e) => e,
2071
- Err(_) => continue,
2072
- };
2073
-
2074
- let path = entry.path();
2075
- let file_name = entry.file_name();
2076
- let file_name_str = file_name.to_string_lossy();
2077
-
2078
- // Skip specific directories/patterns (includes dotfiles via SKIP_FILES)
2079
- if should_skip_path(&path, &file_name_str, &self.config.skip_files) {
2080
- continue;
2081
- }
2082
-
2083
- // Skip components directory during initial scan (we'll validate them when referenced)
2084
- if file_name_str == self.config.components {
2085
- continue;
2086
- }
2087
-
2088
- // Skip nested directories with same name as output dir (from previous bad compilations)
2089
- if !output_dir_name.is_empty() && file_name_str == output_dir_name {
2090
- continue;
2091
- }
2092
-
2093
- if path.is_dir() {
2094
- self.find_html_files_recursive(&path, files, output_dir_name)?;
2095
- } else if path.extension().and_then(|e| e.to_str()) == Some("html") {
2096
- files.push(path);
2097
- }
2098
- }
2099
-
2100
- Ok(())
2101
- }
2102
-
2103
- fn get_output_path(&self, path: &Path, relative_path: &str) -> Result<PathBuf, CompileError> {
2104
- let file_name = path.file_name().unwrap();
2105
-
2106
- let output_path = if relative_path.is_empty() {
2107
- self.config.output.join(file_name)
2108
- } else {
2109
- let output_subdir = self.config.output.join(relative_path);
2110
- fs::create_dir_all(&output_subdir).map_err(|_| {
2111
- CompileError::CreateDirError(output_subdir.display().to_string())
2112
- })?;
2113
- output_subdir.join(file_name)
2114
- };
2115
-
2116
- Ok(output_path)
2117
- }
2118
-
2119
- fn validate_html(&self, content: &str, path: &Path) -> Result<(), CompileError> {
2120
- // Check 1: Unclosed attribute quotes
2121
- self.validate_quote_balance(content, path)?;
2122
-
2123
- // Check 2: Tag balance (unclosed elements break slot extraction)
2124
- self.validate_tag_balance(content, path)
2125
- }
2126
-
2127
- /// Quotes only matter inside tags and attribute values may span lines;
2128
- /// comments and raw content (script, style, pre) are skipped entirely.
2129
- fn validate_quote_balance(&self, content: &str, path: &Path) -> Result<(), CompileError> {
2130
- const RAW_CONTENT: &[&str] = &["script", "style", "pre"];
2131
-
2132
- let bytes = content.as_bytes();
2133
- let mut i = 0;
2134
- let mut line = 1;
2135
-
2136
- while i < bytes.len() {
2137
- if bytes[i] == b'\n' {
2138
- line += 1;
2139
- i += 1;
2140
- } else if bytes[i..].starts_with(b"<!--") {
2141
- match find_bytes(bytes, b"-->", i + 4) {
2142
- Some(end) => {
2143
- line += count_newlines(&bytes[i..end + 3]);
2144
- i = end + 3;
2145
- }
2146
- None => break,
2147
- }
2148
- } else if bytes[i] == b'<'
2149
- && i + 1 < bytes.len()
2150
- && (bytes[i + 1].is_ascii_alphabetic() || bytes[i + 1] == b'/' || bytes[i + 1] == b'!')
2151
- {
2152
- let is_closing = bytes[i + 1] == b'/';
2153
- let name_start = i + if is_closing { 2 } else { 1 };
2154
- let name_end = bytes[name_start..]
2155
- .iter()
2156
- .position(|b| !(b.is_ascii_alphanumeric() || *b == b'-'))
2157
- .map_or(bytes.len(), |p| name_start + p);
2158
- let name = content[name_start..name_end].to_ascii_lowercase();
2159
- i = name_end;
2160
-
2161
- let mut quote: Option<(u8, usize)> = None;
2162
- let mut self_closing = false;
2163
- while i < bytes.len() {
2164
- let b = bytes[i];
2165
- if b == b'\n' {
2166
- line += 1;
2167
- }
2168
- match quote {
2169
- Some((q, _)) if b == q => quote = None,
2170
- Some(_) => {}
2171
- None => match b {
2172
- b'"' | b'\'' => quote = Some((b, line)),
2173
- b'>' => {
2174
- self_closing = bytes[i - 1] == b'/';
2175
- break;
2176
- }
2177
- _ => {}
2178
- },
2179
- }
2180
- i += 1;
2181
- }
2182
-
2183
- if let Some((_, quote_line)) = quote {
2184
- return Err(CompileError::ValidationError {
2185
- file: path.display().to_string(),
2186
- line: quote_line,
2187
- message: "Unclosed quote".to_string(),
2188
- });
2189
- }
2190
- if i >= bytes.len() {
2191
- break;
2192
- }
2193
- i += 1;
2194
-
2195
- if !is_closing && !self_closing && RAW_CONTENT.contains(&name.as_str()) {
2196
- let close = format!("</{}", name);
2197
- match find_bytes_ci(bytes, close.as_bytes(), i) {
2198
- Some(pos) => {
2199
- line += count_newlines(&bytes[i..pos]);
2200
- i = pos;
2201
- }
2202
- None => break, // unterminated raw block — tag balance reports it
2203
- }
2204
- }
2205
- } else {
2206
- i += 1;
2207
- }
2208
- }
2209
-
2210
- Ok(())
2211
- }
2212
-
2213
- fn validate_tag_balance(&self, content: &str, path: &Path) -> Result<(), CompileError> {
2214
- const VOID_ELEMENTS: &[&str] = &[
2215
- "area", "base", "br", "col", "embed", "hr", "img", "input",
2216
- "link", "meta", "param", "source", "track", "wbr",
2217
- ];
2218
- // These are implicitly closed by the parser — don't require explicit close tags
2219
- const IMPLICIT_CLOSE: &[&str] = &[
2220
- "html", "head", "body", "p", "li", "dt", "dd", "option",
2221
- "optgroup", "tr", "td", "th", "thead", "tbody", "tfoot", "colgroup",
2222
- ];
2223
-
2224
- // Strip comments, raw content blocks, and @[...] bindings before tag scanning
2225
- let stripped = Self::strip_for_tag_validation(content);
2226
-
2227
- // Match any tag: opening, self-closing, or closing
2228
- let tag_re = Regex::new(r"</?([a-zA-Z][a-zA-Z0-9-]*)(?:\s[^>]*)?>").unwrap();
2229
-
2230
- // Count opens vs closes per tag name; track line of first open for error reporting
2231
- let mut counts: HashMap<String, (i32, usize)> = HashMap::new();
2232
- for cap in tag_re.captures_iter(&stripped) {
2233
- let full = cap.get(0).unwrap().as_str();
2234
- let name = cap[1].to_lowercase();
2235
- if VOID_ELEMENTS.contains(&name.as_str()) || IMPLICIT_CLOSE.contains(&name.as_str()) {
2236
- continue;
2237
- }
2238
- if full.starts_with("</") {
2239
- counts.entry(name).and_modify(|(c, _)| *c -= 1);
2240
- } else if full.ends_with("/>") {
2241
- // self-closing — no balance change
2242
- } else {
2243
- let offset = cap.get(0).unwrap().start();
2244
- let line = stripped[..offset].matches('\n').count() + 1;
2245
- let entry = counts.entry(name).or_insert((0, line));
2246
- entry.0 += 1;
2247
- }
2248
- }
2249
-
2250
- // Report the first unclosed tag (sorted by line for deterministic output)
2251
- let mut unclosed: Vec<(String, usize)> = counts.into_iter()
2252
- .filter(|(_, (count, _))| *count > 0)
2253
- .map(|(name, (_, line))| (name, line))
2254
- .collect();
2255
- unclosed.sort_by_key(|(_, line)| *line);
2256
-
2257
- if let Some((name, line)) = unclosed.first() {
2258
- return Err(CompileError::ValidationError {
2259
- file: path.display().to_string(),
2260
- line: *line,
2261
- message: format!("Unclosed tag <{}>", name),
2262
- });
2263
- }
2264
-
2265
- Ok(())
2266
- }
2267
-
2268
- fn strip_for_tag_validation(content: &str) -> String {
2269
- // Strip HTML comments <!-- ... --> (includes Vibe syntax: each, if, else, /if, /each)
2270
- let comment_re = Regex::new(r"(?s)<!--.*?-->").unwrap();
2271
- let s = comment_re.replace_all(content, "");
2272
- // Strip @[...] reactive bindings (can contain < and > characters)
2273
- let binding_re = Regex::new(r"@\[[^\]]*\]").unwrap();
2274
- let s = binding_re.replace_all(&s, "");
2275
- // Strip content inside <script>, <style>, <pre> to avoid parsing embedded code as HTML
2276
- let raw_re = Regex::new(r"(?si)(<(?:script|style|pre)(?:\s[^>]*)?>).*?(</(?:script|style|pre)>)").unwrap();
2277
- raw_re.replace_all(&s, "$1$2").into_owned()
2278
- }
2279
-
2280
- /// Copy an entire directory recursively
2281
- fn copy_directory(
2282
- &mut self,
2283
- dir: &Path,
2284
- relative_path: &str,
2285
- canonical_source: &Path,
2286
- stats: &mut CompileStats,
2287
- ) -> Result<(), CompileError> {
2288
- let entries = fs::read_dir(dir).map_err(|e| CompileError::ReadError {
2289
- path: dir.display().to_string(),
2290
- source: e,
2291
- })?;
2292
-
2293
- for entry in entries.flatten() {
2294
- let path = entry.path();
2295
- let file_name = path.file_name().unwrap().to_str().unwrap();
2296
-
2297
- // Skip files/directories matching skip patterns
2298
- if should_skip_path(&path, file_name, &self.config.skip_files) {
2299
- continue;
2300
- }
2301
-
2302
- if path.is_dir() {
2303
- let new_relative = format!("{}/{}", relative_path, file_name);
2304
- self.copy_directory(&path, &new_relative, canonical_source, stats)?;
2305
- } else {
2306
- self.copy_file(&path, relative_path)?;
2307
- stats.files_copied += 1;
2308
- if let Some(ref mut logger) = self.logger {
2309
- logger.log(&path, FileOperation::Copied, canonical_source);
2310
- }
2311
- }
2312
- }
2313
-
2314
- Ok(())
2315
- }
2316
-
2317
- /// Copy node_modules as-is to output
2318
- fn copy_node_modules_as_is(&self) -> Result<(), CompileError> {
2319
- let src = self.config.working_dir.join("node_modules");
2320
- let dest = self.config.output.join("node_modules");
2321
-
2322
- // Remove existing node_modules in output if it exists
2323
- if dest.exists() {
2324
- fs::remove_dir_all(&dest).map_err(|e| CompileError::WriteError {
2325
- path: dest.display().to_string(),
2326
- source: e,
2327
- })?;
2328
- }
2329
-
2330
- // Copy recursively
2331
- copy_dir_recursive(&src, &dest)?;
2332
-
2333
- Ok(())
2334
- }
2335
-
2336
- /// Install production dependencies directly into output directory
2337
- fn copy_production_node_modules(&self) -> Result<(), CompileError> {
2338
- let pkg_manager = detect_package_manager(&self.config.working_dir)?;
2339
-
2340
- if self.verbose {
2341
- println!("\n Detected package manager: {}", pkg_manager);
2342
- println!(" Installing production dependencies to output...");
2343
- }
2344
-
2345
- // 1. Copy package.json to output directory
2346
- let package_json_src = self.config.working_dir.join("package.json");
2347
- let package_json_dest = self.config.output.join("package.json");
2348
-
2349
- fs::copy(&package_json_src, &package_json_dest).map_err(|e| CompileError::WriteError {
2350
- path: package_json_dest.display().to_string(),
2351
- source: e,
2352
- })?;
2353
-
2354
- // 2. Copy lockfile if it exists (needed for reproducible installs)
2355
- let lockfile_name = match pkg_manager.as_str() {
2356
- "bun" => "bun.lockb",
2357
- "pnpm" => "pnpm-lock.yaml",
2358
- "yarn" => "yarn.lock",
2359
- "npm" => "package-lock.json",
2360
- _ => "",
2361
- };
2362
-
2363
- if !lockfile_name.is_empty() {
2364
- let lockfile_src = self.config.working_dir.join(lockfile_name);
2365
- let lockfile_dest = self.config.output.join(lockfile_name);
2366
- if lockfile_src.exists() {
2367
- fs::copy(&lockfile_src, &lockfile_dest).ok();
2368
- }
2369
- }
2370
-
2371
- // 3. Install production dependencies directly in output directory
2372
- run_command(&pkg_manager, &["install", "--production"], &self.config.output)?;
2373
-
2374
- // 4. Cleanup: remove package.json and lockfile from output
2375
- fs::remove_file(&package_json_dest).ok();
2376
- if !lockfile_name.is_empty() {
2377
- let lockfile_dest = self.config.output.join(lockfile_name);
2378
- fs::remove_file(&lockfile_dest).ok();
2379
- }
2380
-
2381
- if self.verbose {
2382
- println!(" Installed production dependencies to output");
2383
- }
2384
-
2385
- Ok(())
2386
- }
2387
- }
2388
-
2389
- /// Detect package manager from lockfiles
2390
- fn detect_package_manager(dir: &Path) -> Result<String, CompileError> {
2391
- if dir.join("bun.lockb").exists() {
2392
- return Ok("bun".to_string());
2393
- }
2394
- if dir.join("pnpm-lock.yaml").exists() {
2395
- return Ok("pnpm".to_string());
2396
- }
2397
- if dir.join("yarn.lock").exists() {
2398
- return Ok("yarn".to_string());
2399
- }
2400
- if dir.join("package-lock.json").exists() {
2401
- return Ok("npm".to_string());
2402
- }
2403
-
2404
- Err(CompileError::PackageManagerNotFound)
2405
- }
2406
-
2407
- /// Run a package manager command
2408
- fn run_command(pkg_manager: &str, args: &[&str], cwd: &Path) -> Result<(), CompileError> {
2409
- let output = Command::new(pkg_manager)
2410
- .args(args)
2411
- .current_dir(cwd)
2412
- .output()
2413
- .map_err(|e| CompileError::CommandError(format!("{} not found: {}", pkg_manager, e)))?;
2414
-
2415
- if !output.status.success() {
2416
- let stderr = String::from_utf8_lossy(&output.stderr);
2417
- return Err(CompileError::CommandError(format!(
2418
- "{} {} failed: {}",
2419
- pkg_manager,
2420
- args.join(" "),
2421
- stderr
2422
- )));
2423
- }
2424
-
2425
- Ok(())
2426
- }
2427
-
2428
- /// Recursively copy a directory
2429
- fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), CompileError> {
2430
- if !dest.exists() {
2431
- fs::create_dir_all(dest).map_err(|_| CompileError::CreateDirError(dest.display().to_string()))?;
2432
- }
2433
-
2434
- for entry in fs::read_dir(src).map_err(|e| CompileError::ReadError {
2435
- path: src.display().to_string(),
2436
- source: e,
2437
- })? {
2438
- let entry = entry.map_err(|e| CompileError::ReadError {
2439
- path: src.display().to_string(),
2440
- source: e,
2441
- })?;
2442
- let path = entry.path();
2443
- let file_name = entry.file_name();
2444
- let dest_path = dest.join(&file_name);
2445
-
2446
- if path.is_dir() {
2447
- // Cache Directory Tagging spec: a directory carrying a signed
2448
- // CACHEDIR.TAG (cargo target/, many build caches) declares itself
2449
- // regenerable and skippable for copy tools. A symlinked local
2450
- // package would otherwise drag gigabytes of build artifacts into
2451
- // the as-is node_modules copy.
2452
- if is_cachedir_tagged(&path) {
2453
- continue;
2454
- }
2455
- copy_dir_recursive(&path, &dest_path)?;
2456
- } else {
2457
- fs::copy(&path, &dest_path).map_err(|e| CompileError::WriteError {
2458
- path: dest_path.display().to_string(),
2459
- source: e,
2460
- })?;
2461
- }
2462
- }
2463
-
2464
- Ok(())
2465
- }
2466
-
2467
- fn is_cachedir_tagged(dir: &Path) -> bool {
2468
- fs::read(dir.join("CACHEDIR.TAG"))
2469
- .map(|bytes| bytes.starts_with(b"Signature: 8a477f597d28d172789f06886806bc55"))
2470
- .unwrap_or(false)
2471
- }
2472
-
2473
- /// Basic HTML minification
2474
- fn minify_html(html: &str) -> String {
2475
- let mut result = String::with_capacity(html.len());
2476
- let mut in_raw = false;
2477
- let mut last_was_space = false;
2478
-
2479
- for line in html.lines() {
2480
- let trimmed = line.trim();
2481
-
2482
- // <pre>, <script> and <style> carry significant whitespace and must
2483
- // survive minification verbatim: <pre> is literal text, while a JS
2484
- // `//` line comment or ASI in <script> breaks the moment its trailing
2485
- // newline is collapsed into a space (the comment swallows the rest of
2486
- // the script). Keep these blocks line-for-line, exactly as <pre> always
2487
- // did. The opening-tag line enters the block before we emit it; the
2488
- // closing-tag line leaves it (and collapses, which only tightens the
2489
- // bare `</pre>` / `</script>` / `</style>`).
2490
- if trimmed.contains("<pre") || trimmed.contains("<script") || trimmed.contains("<style") {
2491
- in_raw = true;
2492
- }
2493
- if trimmed.contains("</pre>") || trimmed.contains("</script>") || trimmed.contains("</style>") {
2494
- in_raw = false;
2495
- }
2496
-
2497
- if in_raw {
2498
- result.push_str(line);
2499
- result.push('\n');
2500
- last_was_space = false;
2501
- } else {
2502
- // The line break preceding this line is whitespace: collapse it to a
2503
- // single space so attributes/text split across lines don't glue
2504
- // together (e.g. a multi-line <meta name=... content=...> tag).
2505
- // The >\s+< pass below re-tightens genuine tag boundaries.
2506
- if !last_was_space && !result.is_empty() {
2507
- result.push(' ');
2508
- last_was_space = true;
2509
- }
2510
- for c in trimmed.chars() {
2511
- if c.is_whitespace() {
2512
- if !last_was_space {
2513
- result.push(' ');
2514
- last_was_space = true;
2515
- }
2516
- } else {
2517
- result.push(c);
2518
- last_was_space = false;
2519
- }
2520
- }
2521
- }
2522
- }
2523
-
2524
- // Collapse whitespace between tags to a SINGLE space — never delete it.
2525
- // Inter-element whitespace is significant for inline content: the browser
2526
- // (and the non-compiled runtime) render `<em>a</em> <em>b</em>` with a space,
2527
- // so dropping it (`><`) glued words onto preceding inline elements (the
2528
- // status-chip "💠Concussion" parity bug). One space matches both the browser's
2529
- // own collapsing and the non-minified/runtime output; where the space is
2530
- // insignificant (between block/table/list/head elements) the parser discards
2531
- // it anyway, exactly as it does for the non-minified newline.
2532
- let result = regex::Regex::new(r">\s+<")
2533
- .unwrap()
2534
- .replace_all(&result, "> <")
2535
- .to_string();
2536
-
2537
- result.trim().to_string()
2538
- }
2539
-
2540
- fn find_bytes(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
2541
- haystack[from..].windows(needle.len()).position(|w| w == needle).map(|p| p + from)
2542
- }
2543
-
2544
- fn find_bytes_ci(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
2545
- haystack[from..].windows(needle.len()).position(|w| w.eq_ignore_ascii_case(needle)).map(|p| p + from)
2546
- }
2547
-
2548
- fn count_newlines(bytes: &[u8]) -> usize {
2549
- bytes.iter().filter(|&&b| b == b'\n').count()
2550
- }
2551
-
2552
- #[cfg(test)]
2553
- mod tests {
2554
- use super::*;
2555
-
2556
- #[test]
2557
- fn minify_preserves_script_newlines_so_line_comments_dont_swallow_code() {
2558
- // A `//` line comment inside a component script relies on its trailing
2559
- // newline. If minify collapses newlines into spaces, the comment eats
2560
- // the rest of the script → SyntaxError at runtime (new Function).
2561
- let html = "<page>\n\
2562
- <script type=\"module\">\n\
2563
- \x20 const a = 1; // explain a\n\
2564
- \x20 // keep explaining\n\
2565
- \x20 const b = 2;\n\
2566
- </script>\n\
2567
- </page>";
2568
-
2569
- let out = minify_html(html);
2570
-
2571
- // The code after the comments must still be reachable, i.e. on its own
2572
- // line rather than glued behind the `//`.
2573
- let script = &out[out.find("<script").unwrap()..out.find("</script>").unwrap()];
2574
- assert!(
2575
- script.contains('\n'),
2576
- "script newlines were collapsed, // comment swallows following code: {script:?}"
2577
- );
2578
- assert!(out.contains("const b = 2"), "code after // comment lost: {out:?}");
2579
-
2580
- // Surrounding HTML must still be minified (tag boundaries tightened).
2581
- assert!(out.contains("<page><script"), "non-script HTML not minified: {out:?}");
2582
- }
2583
-
2584
- #[test]
2585
- fn minify_preserves_style_newlines() {
2586
- let html = "<page>\n\
2587
- <style>\n\
2588
- \x20 a { color: red; }\n\
2589
- \x20 b { color: blue; }\n\
2590
- </style>\n\
2591
- </page>";
2592
-
2593
- let out = minify_html(html);
2594
- let style = &out[out.find("<style").unwrap()..out.find("</style>").unwrap()];
2595
- assert!(style.contains('\n'), "style newlines collapsed: {style:?}");
2596
- }
2597
-
2598
- #[test]
2599
- fn atomic_write_replaces_content_without_leaving_tmp_files() {
2600
- let dir = std::env::temp_dir().join("vibe_atomic_write_test");
2601
- let _ = fs::remove_dir_all(&dir);
2602
- fs::create_dir_all(&dir).unwrap();
2603
- let target = dir.join("page.html");
2604
-
2605
- atomic_write(&target, "first").unwrap();
2606
- assert_eq!(fs::read_to_string(&target).unwrap(), "first");
2607
-
2608
- // Overwriting an existing file goes through the same tmp+rename path.
2609
- atomic_write(&target, "second, longer content").unwrap();
2610
- assert_eq!(fs::read_to_string(&target).unwrap(), "second, longer content");
2611
-
2612
- let leftovers: Vec<String> = fs::read_dir(&dir)
2613
- .unwrap()
2614
- .filter_map(|e| e.ok())
2615
- .map(|e| e.file_name().to_string_lossy().into_owned())
2616
- .filter(|n| n != "page.html")
2617
- .collect();
2618
- assert!(leftovers.is_empty(), "temp artifacts left behind: {leftovers:?}");
2619
- }
2620
-
2621
- // A minimal on-disk project: source with one page carrying a distinctive
2622
- // binding, empty components dir, output dir sibling. Returns (config, page
2623
- // source path, compiled page output path, manifest path).
2624
- // nodeModulesAsIs copies must skip cache-tagged directories (the Cache
2625
- // Directory Tagging spec: cargo target/, many build caches). A symlinked
2626
- // local package dragging its cargo target/ along turned a 15MB copy into
2627
- // gigabytes.
2628
- #[test]
2629
- fn as_is_copy_skips_cachedir_tagged_directories() {
2630
- let dir = std::env::temp_dir().join("vibe-cachedir-copy-test");
2631
- let _ = fs::remove_dir_all(&dir);
2632
- let src = dir.join("node_modules/pkg");
2633
- fs::create_dir_all(src.join("runtime")).unwrap();
2634
- fs::create_dir_all(src.join("build/target/release")).unwrap();
2635
- fs::write(src.join("index.js"), "export default 1;").unwrap();
2636
- fs::write(src.join("runtime/state.js"), "export const s = 1;").unwrap();
2637
- fs::write(
2638
- src.join("build/target/CACHEDIR.TAG"),
2639
- "Signature: 8a477f597d28d172789f06886806bc55\n",
2640
- )
2641
- .unwrap();
2642
- fs::write(src.join("build/target/release/artifact"), "big").unwrap();
2643
-
2644
- let dest = dir.join("out/node_modules");
2645
- copy_dir_recursive(&dir.join("node_modules"), &dest).unwrap();
2646
-
2647
- assert!(dest.join("pkg/index.js").exists());
2648
- assert!(dest.join("pkg/runtime/state.js").exists());
2649
- assert!(dest.join("pkg/build").exists());
2650
- assert!(!dest.join("pkg/build/target").exists(), "cache-tagged dir was copied");
2651
-
2652
- let _ = fs::remove_dir_all(&dir);
2653
- }
2654
-
2655
- // Watch mode re-runs compile_spa on any pages-tree change: an edited page
2656
- // re-transforms and the shell recomposes (title/head/route table), a
2657
- // removed page's fragment is pruned from the output mirror and its route
2658
- // leaves the table. Full-rerun semantics keep add/edit/remove one path.
2659
- #[test]
2660
- fn spa_recompile_syncs_fragments_shell_and_routes() {
2661
- let dir = std::env::temp_dir().join("vibe-spa-watch-test");
2662
- let _ = fs::remove_dir_all(&dir);
2663
- let source = dir.join("src");
2664
- fs::create_dir_all(source.join("pages")).unwrap();
2665
- fs::create_dir_all(source.join("components")).unwrap();
2666
- let page = |title: &str, body: &str| {
2667
- format!(
2668
- "<!DOCTYPE html>\n<html><head><title>{}</title></head>\n<body vibe-fouc>{}</body></html>\n",
2669
- title, body
2670
- )
2671
- };
2672
- fs::write(source.join("pages/index.html"), page("Home", "<h1>home</h1>")).unwrap();
2673
- fs::write(source.join("pages/about.html"), page("About", "<h1>about</h1>")).unwrap();
2674
-
2675
- let config = Config {
2676
- source: source.clone(),
2677
- output: dir.join("out"),
2678
- _source_str: String::new(),
2679
- _output_str: String::new(),
2680
- components: "components".to_string(),
2681
- pages: "pages".to_string(),
2682
- _assets: String::new(),
2683
- root: None,
2684
- minify: false,
2685
- elements_as_is: true,
2686
- source_maps: false,
2687
- reserved_elements: Vec::new(),
2688
- skip_files: Vec::new(),
2689
- node_modules_as_is: false,
2690
- components_as_is: true,
2691
- runtime_as_is: false,
2692
- iterations_as_is: false,
2693
- no_clean: false,
2694
- fouc_as_is: false,
2695
- spa: true,
2696
- working_dir: dir.clone(),
2697
- };
2698
-
2699
- let mut compiler = Compiler::new(config.clone(), false);
2700
- let mut parser = HtmlParser::new(config.components_path());
2701
- parser.load_elements().unwrap();
2702
- compiler.compile().unwrap();
2703
-
2704
- let shell_path = config.output.join("index.html");
2705
- let fragment = config.output.join("components/vibe-spa/about.html");
2706
- let shell = fs::read_to_string(&shell_path).unwrap();
2707
- assert!(shell.contains("\"route\": \"/about\""));
2708
- assert!(shell.contains("<title>Home</title>"));
2709
- assert!(fragment.exists());
2710
-
2711
- // Edited page: fragment re-transforms, shell recomposes with the new
2712
- // harvested title.
2713
- fs::write(source.join("pages/about.html"), page("Regenerated", "<h1>about v2</h1>")).unwrap();
2714
- compiler.compile_spa(&parser).unwrap();
2715
- let shell = fs::read_to_string(&shell_path).unwrap();
2716
- assert!(shell.contains("\"title\": \"Regenerated\""));
2717
- assert!(fs::read_to_string(&fragment).unwrap().contains("about v2"));
2718
-
2719
- // Removed page: route leaves the table, orphan fragment is pruned.
2720
- fs::remove_file(source.join("pages/about.html")).unwrap();
2721
- compiler.compile_spa(&parser).unwrap();
2722
- let shell = fs::read_to_string(&shell_path).unwrap();
2723
- assert!(!shell.contains("\"route\": \"/about\""));
2724
- assert!(!fragment.exists());
2725
-
2726
- let _ = fs::remove_dir_all(&dir);
2727
- }
2728
-
2729
- fn manifest_test_project(name: &str) -> (Config, PathBuf, PathBuf, PathBuf) {
2730
- let dir = std::env::temp_dir().join(format!("vibe_{}_test", name));
2731
- let _ = fs::remove_dir_all(&dir);
2732
- let source = dir.join("src");
2733
- let output = dir.join("out");
2734
- fs::create_dir_all(source.join("components")).unwrap();
2735
- fs::write(
2736
- source.join("index.html"),
2737
- "<!doctype html>\n<html><head><title>t</title></head>\n\
2738
- <body vibe>\n<page-home><h1>@[uniqueMarker123]</h1></page-home>\n</body></html>\n",
2739
- )
2740
- .unwrap();
2741
-
2742
- let config = Config {
2743
- source: source.clone(),
2744
- output: output.clone(),
2745
- _source_str: String::new(),
2746
- _output_str: String::new(),
2747
- components: "components".to_string(),
2748
- pages: "pages".to_string(),
2749
- _assets: String::new(),
2750
- root: None,
2751
- minify: false,
2752
- elements_as_is: false,
2753
- source_maps: false,
2754
- reserved_elements: Vec::new(),
2755
- skip_files: Vec::new(),
2756
- node_modules_as_is: false,
2757
- components_as_is: false,
2758
- runtime_as_is: false,
2759
- iterations_as_is: false,
2760
- no_clean: false,
2761
- fouc_as_is: false,
2762
- spa: false,
2763
- working_dir: dir.clone(),
2764
- };
2765
-
2766
- let page_src = source.join("index.html");
2767
- let page_out = output.join("index.html");
2768
- let manifest = output.join("vibe-hyperspeed").join("index.html.manifest.js");
2769
- (config, page_src, page_out, manifest)
2770
- }
2771
-
2772
- // The full build always mirrors components/ verbatim into the output —
2773
- // runtime-fetched components (`<component src="@[page.src]">`, iter-prop
2774
- // roots, components_as_is) are served from that mirror. Watch mode must
2775
- // keep the same contract: an edited component reaches the mirror even when
2776
- // NO page inlines it (zero dependents), and a deleted component leaves it.
2777
- #[test]
2778
- fn changed_component_is_remirrored_to_output() {
2779
- let (config, _page_src, _page_out, _manifest) = manifest_test_project("component_mirror");
2780
- let source_component = config.source.join("components").join("widget.html");
2781
- let mirrored = config.output.join("components").join("widget.html");
2782
- let nested_src = config.source.join("components").join("spa").join("pane.html");
2783
- let nested_out = config.output.join("components").join("spa").join("pane.html");
2784
- fs::write(&source_component, "<spa-widget>v1</spa-widget>\n").unwrap();
2785
-
2786
- let mut compiler = Compiler::new(config, false);
2787
- compiler.compile().expect("compile should succeed");
2788
- assert_eq!(
2789
- fs::read_to_string(&mirrored).unwrap(),
2790
- "<spa-widget>v1</spa-widget>\n",
2791
- "sanity: full build mirrors the component"
2792
- );
2793
-
2794
- // Edit the component — no page references it, so the dependency graph
2795
- // maps it to zero pages; the mirror must still track the change.
2796
- fs::write(&source_component, "<spa-widget>v2</spa-widget>\n").unwrap();
2797
- let copied = compiler
2798
- .mirror_component_files(&[source_component.clone()])
2799
- .expect("mirroring should succeed");
2800
- assert_eq!(copied, 1);
2801
- assert_eq!(
2802
- fs::read_to_string(&mirrored).unwrap(),
2803
- "<spa-widget>v2</spa-widget>\n",
2804
- "edited component did not reach the output mirror"
2805
- );
2806
-
2807
- // A component created mid-session (parent dirs may not exist yet).
2808
- fs::create_dir_all(nested_src.parent().unwrap()).unwrap();
2809
- fs::write(&nested_src, "<spa-pane>new</spa-pane>\n").unwrap();
2810
- compiler
2811
- .mirror_component_files(&[nested_src.clone()])
2812
- .expect("mirroring a new nested component should succeed");
2813
- assert_eq!(fs::read_to_string(&nested_out).unwrap(), "<spa-pane>new</spa-pane>\n");
2814
-
2815
- // Deleting the source removes the mirrored copy.
2816
- fs::remove_file(&source_component).unwrap();
2817
- compiler
2818
- .mirror_component_files(&[source_component])
2819
- .expect("mirroring a deletion should succeed");
2820
- assert!(!mirrored.exists(), "deleted component still present in the output mirror");
2821
- }
2822
-
2823
- // The watcher race: another writer truncates/rewrites a compiled page on
2824
- // disk between our compile and our manifest pass. The manifest must be
2825
- // built from the HTML this compiler just produced in memory — never from a
2826
- // disk read-back — or a torn read yields a valid-but-empty manifest and the
2827
- // page hydrates to a blank screen.
2828
- #[test]
2829
- fn manifest_survives_output_corruption_between_compile_and_manifests() {
2830
- let (config, _page_src, page_out, manifest) =
2831
- manifest_test_project("manifest_memory_full");
2832
-
2833
- let mut compiler = Compiler::new(config, false);
2834
- compiler.compile().expect("compile should succeed");
2835
- assert!(
2836
- fs::read_to_string(&page_out).unwrap().contains("uniqueMarker123"),
2837
- "sanity: compiled page carries the binding"
2838
- );
2839
-
2840
- // Simulate the concurrent writer: the on-disk page is now a shell.
2841
- fs::write(&page_out, "<!doctype html>\n<html><head></head><body></body></html>\n").unwrap();
2842
-
2843
- compiler.generate_manifests().expect("manifest generation should succeed");
2844
-
2845
- let manifest_js = fs::read_to_string(&manifest).expect("manifest should exist");
2846
- assert!(
2847
- manifest_js.contains("uniqueMarker123"),
2848
- "manifest was built from the corrupted disk file instead of the in-memory compile output"
2849
- );
2850
- }
2851
-
2852
- // Same property on the incremental watch path (generate_manifests_for_files),
2853
- // which is where the two-watcher race actually corrupted manifests.
2854
- #[test]
2855
- fn incremental_manifest_survives_output_corruption() {
2856
- let (config, page_src, page_out, manifest) =
2857
- manifest_test_project("manifest_memory_incremental");
2858
-
2859
- let mut compiler = Compiler::new(config.clone(), false);
2860
- let mut parser = HtmlParser::new(config.components_path());
2861
- parser.load_elements().unwrap();
2862
-
2863
- fs::create_dir_all(&config.output).unwrap();
2864
- compiler
2865
- .compile_specific_html_files(&[page_src.clone()], &parser)
2866
- .expect("incremental compile should succeed");
2867
-
2868
- fs::write(&page_out, "<!doctype html>\n<html><head></head><body></body></html>\n").unwrap();
2869
-
2870
- compiler
2871
- .generate_manifests_for_files(&[page_src])
2872
- .expect("incremental manifest generation should succeed");
2873
-
2874
- let manifest_js = fs::read_to_string(&manifest).expect("manifest should exist");
2875
- assert!(
2876
- manifest_js.contains("uniqueMarker123"),
2877
- "incremental manifest was built from the corrupted disk file instead of the in-memory compile output"
2878
- );
2879
- }
2880
- }