@ape-egg/vibe 2.1.22 → 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 (56) hide show
  1. package/README.md +112 -5
  2. package/boot.js +4 -4
  3. package/component.js +27 -29
  4. package/hot-module-refresh.js +4 -4
  5. package/index.js +26 -17
  6. package/llms.txt +36 -5
  7. package/package.json +20 -14
  8. package/runtime/affected.js +159 -36
  9. package/runtime/cleanup.js +45 -1
  10. package/runtime/component.js +360 -98
  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 +277 -110
  15. package/runtime/index.js +189 -65
  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 +81 -11
  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 +206 -0
  27. package/vibe.css +8 -4
  28. package/CHANGELOG.md +0 -1159
  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 -2522
  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 -15
  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/state_extractor.rs +0 -263
  47. package/compiler/src/compiler/value_stamper.rs +0 -921
  48. package/compiler/src/compiler/watcher.rs +0 -1147
  49. package/compiler/src/config.rs +0 -239
  50. package/compiler/src/main.rs +0 -347
  51. package/compiler/src/parser/element.rs +0 -96
  52. package/compiler/src/parser/html.rs +0 -1004
  53. package/compiler/src/parser/mod.rs +0 -8
  54. package/runtime/pre-compiled-manifest.test.mjs +0 -58
  55. package/runtime/scope.js +0 -50
  56. package/test-results/.last-run.json +0 -4
@@ -1,2522 +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
- }
285
-
286
- pub struct CompileStats {
287
- pub files_compiled: usize,
288
- pub files_copied: usize,
289
- pub internal_components_unique: usize,
290
- pub external_components_unique: usize,
291
- pub internal_components_total: usize,
292
- pub external_components_total: usize,
293
- pub compile_time_ms: f64,
294
- pub copy_time_ms: f64,
295
- pub components_time_ms: f64,
296
- pub validation_time_ms: Option<f64>,
297
- pub node_modules_time_ms: Option<f64>,
298
- pub package_manager: Option<String>,
299
- pub node_modules_copied_as_is: bool,
300
- pub components_as_is: bool,
301
- }
302
-
303
- pub struct ManifestStats {
304
- pub pages_processed: usize,
305
- pub pages_skipped: usize,
306
- pub total_time_ms: f64,
307
- }
308
-
309
- #[derive(Debug, Clone, PartialEq)]
310
- enum FileOperation {
311
- Compiled,
312
- Copied,
313
- }
314
-
315
- struct VerboseLogger {
316
- operations: BTreeMap<String, Vec<(String, FileOperation)>>, // dir -> [(filename, operation)]
317
- component_counts: HashMap<String, usize>, // component src -> count
318
- component_children: HashMap<String, HashSet<String>>, // component src -> direct children
319
- components_as_is: bool,
320
- }
321
-
322
- impl VerboseLogger {
323
- fn new(components_as_is: bool) -> Self {
324
- Self {
325
- operations: BTreeMap::new(),
326
- component_counts: HashMap::new(),
327
- component_children: HashMap::new(),
328
- components_as_is,
329
- }
330
- }
331
-
332
- fn log_component_occurrence(&mut self, src: String) {
333
- // Increment count for this component
334
- *self.component_counts.entry(src).or_insert(0) += 1;
335
- }
336
-
337
- fn log_component_children(&mut self, src: String, children: Vec<String>) {
338
- // Track children relationships
339
- if !children.is_empty() {
340
- self.component_children
341
- .entry(src)
342
- .or_insert_with(HashSet::new)
343
- .extend(children);
344
- }
345
- }
346
-
347
- fn log(&mut self, path: &Path, operation: FileOperation, source_root: &Path) {
348
- let relative = path.strip_prefix(source_root).unwrap_or(path);
349
- let dir = relative.parent()
350
- .map(|p| p.to_string_lossy().to_string())
351
- .unwrap_or_else(|| String::from(""));
352
- let filename = relative.file_name()
353
- .map(|f| f.to_string_lossy().to_string())
354
- .unwrap_or_default();
355
-
356
- self.operations
357
- .entry(dir)
358
- .or_insert_with(Vec::new)
359
- .push((filename, operation));
360
- }
361
-
362
- fn print_components(&self) {
363
- if self.components_as_is {
364
- println!("\n{} (0 {}, 0 {}) - \"components-as-is\": true",
365
- "Compiled components".bright_cyan(),
366
- "internal".bright_green(),
367
- "external".bright_magenta()
368
- );
369
- return;
370
- }
371
-
372
- if self.component_counts.is_empty() {
373
- return;
374
- }
375
-
376
- // Count internal vs external
377
- let mut internal_total = 0;
378
- let mut external_total = 0;
379
- for (src, count) in &self.component_counts {
380
- if src.starts_with("http://") || src.starts_with("https://") {
381
- external_total += count;
382
- } else {
383
- internal_total += count;
384
- }
385
- }
386
-
387
- println!("\n{} ({} {}, {} {})",
388
- "Compiled components".bright_cyan().bold(),
389
- internal_total,
390
- "internal".bright_green(),
391
- external_total,
392
- "external".bright_magenta()
393
- );
394
- println!();
395
-
396
- // Sort components by count (descending) then alphabetically
397
- let mut components: Vec<_> = self.component_counts.iter().collect();
398
- components.sort_by(|(a_src, a_count), (b_src, b_count)| {
399
- b_count.cmp(a_count).then_with(|| a_src.cmp(b_src))
400
- });
401
-
402
- // Find top-level components (those not referenced as children of others)
403
- let all_children: HashSet<String> = self.component_children
404
- .values()
405
- .flat_map(|children| children.iter().cloned())
406
- .collect();
407
-
408
- // Print each top-level component with its tree
409
- for (src, count) in &components {
410
- // Only show as top-level if it's not a child of another component
411
- if !all_children.contains(*src) {
412
- let is_external = src.starts_with("http://") || src.starts_with("https://");
413
-
414
- let display = if is_external {
415
- format!("{} ({})", src, count).bright_magenta()
416
- } else {
417
- format!("{} ({})", src, count).bright_green()
418
- };
419
-
420
- println!("{}", display);
421
-
422
- // Print children if any
423
- if let Some(children) = self.component_children.get(*src) {
424
- let mut children_vec: Vec<_> = children.iter().collect();
425
- children_vec.sort();
426
-
427
- for child in children_vec {
428
- let is_external = child.starts_with("http://") || child.starts_with("https://");
429
- let display = if is_external {
430
- format!(" └─ {}", child).bright_magenta()
431
- } else {
432
- format!(" └─ {}", child).bright_green()
433
- };
434
- println!("{}", display);
435
- }
436
- }
437
- }
438
- }
439
- }
440
-
441
- fn print_compiled(&self) {
442
- self.print_operations("Compiled HTML", FileOperation::Compiled);
443
- }
444
-
445
- fn print_copied(&self) {
446
- self.print_operations("Copied files", FileOperation::Copied);
447
- }
448
-
449
- fn print_operations(&self, label: &str, op_type: FileOperation) {
450
- let matching: Vec<_> = self.operations.iter()
451
- .map(|(dir, files)| {
452
- let filtered: Vec<_> = files.iter()
453
- .filter(|(_, op)| {
454
- match (&op_type, op) {
455
- (FileOperation::Compiled, FileOperation::Compiled) => true,
456
- (FileOperation::Copied, FileOperation::Copied) => true,
457
- _ => false,
458
- }
459
- })
460
- .map(|(name, _)| name.clone())
461
- .collect();
462
- (dir.clone(), filtered)
463
- })
464
- .filter(|(_, files)| !files.is_empty())
465
- .collect();
466
-
467
- if matching.is_empty() {
468
- return;
469
- }
470
-
471
- let total_count: usize = matching.iter().map(|(_, files)| files.len()).sum();
472
- println!("\n{} ({})", label.bright_cyan().bold(), total_count);
473
- println!();
474
-
475
- // Root files first
476
- if let Some((_, files)) = matching.iter().find(|(dir, _)| dir.is_empty()) {
477
- for file in files {
478
- println!(" {}", file);
479
- }
480
- }
481
-
482
- // Then directories hierarchically
483
- self.print_directories(&matching, "", 1);
484
- }
485
-
486
- fn print_directories(&self, matching: &[(String, Vec<String>)], parent: &str, depth: usize) {
487
- // Find all directories at this level
488
- let mut dirs_at_level: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
489
-
490
- for (dir, _) in matching {
491
- if dir.is_empty() {
492
- continue;
493
- }
494
-
495
- let remainder = if parent.is_empty() {
496
- if dir.starts_with(parent) {
497
- dir.as_str()
498
- } else {
499
- continue;
500
- }
501
- } else {
502
- if dir.starts_with(parent) && dir.len() > parent.len() {
503
- &dir[parent.len() + 1..] // +1 for the separator
504
- } else {
505
- continue;
506
- }
507
- };
508
-
509
- if let Some(slash_pos) = remainder.find('/') {
510
- dirs_at_level.insert(remainder[..slash_pos].to_string());
511
- } else if !remainder.is_empty() {
512
- dirs_at_level.insert(remainder.to_string());
513
- }
514
- }
515
-
516
- // Print each directory
517
- for dir_name in dirs_at_level {
518
- let full_path = if parent.is_empty() {
519
- dir_name.clone()
520
- } else {
521
- format!("{}/{}", parent, dir_name)
522
- };
523
-
524
- let indent = " ".repeat(depth);
525
-
526
- // Count files directly in this directory (not subdirectories)
527
- let file_count = matching.iter()
528
- .find(|(d, _)| d == &full_path)
529
- .map(|(_, files)| files.len())
530
- .unwrap_or(0);
531
-
532
- if file_count > 0 {
533
- println!("{}{} ({})", indent, format!("/{}", dir_name).cyan(), file_count);
534
- } else {
535
- println!("{}{}", indent, format!("/{}", dir_name).cyan());
536
- }
537
-
538
- // Print files in this directory
539
- if let Some((_, files)) = matching.iter().find(|(d, _)| d == &full_path) {
540
- for file in files {
541
- println!("{} {}", indent, file);
542
- }
543
- }
544
-
545
- // Recurse into subdirectories
546
- self.print_directories(matching, &full_path, depth + 1);
547
- }
548
- }
549
- }
550
-
551
- pub struct Compiler {
552
- config: Config,
553
- verbose: bool,
554
- logger: Option<VerboseLogger>,
555
- unique_components: HashSet<String>,
556
- /// Cache for all components (both internal paths and external URLs)
557
- component_cache: std::collections::HashMap<String, String>,
558
- /// Compiled (pre-stamp) HTML of every page this compiler wrote, keyed by
559
- /// output path. Manifest generation builds from these bytes — never from a
560
- /// disk read-back — so a concurrent writer rewriting the output directory
561
- /// (e.g. a second compiler) can't feed a torn read into a manifest.
562
- compiled_html: HashMap<PathBuf, String>,
563
- }
564
-
565
- impl Compiler {
566
- pub fn new(config: Config, verbose: bool) -> Self {
567
- let logger = if verbose { Some(VerboseLogger::new(config.components_as_is)) } else { None };
568
- Self {
569
- config,
570
- verbose,
571
- logger,
572
- unique_components: HashSet::new(),
573
- component_cache: HashMap::new(),
574
- compiled_html: HashMap::new(),
575
- }
576
- }
577
-
578
- /// Drop only the named cache entries, forcing those components to be
579
- /// re-read + re-inlined on the next compile while every other component is
580
- /// reused from cache. `keys` are normalized component srcs (e.g.
581
- /// `/components/Sidebar.html`). Returns how many entries were actually
582
- /// removed. This is the incremental-watch path: invalidate the edited
583
- /// component and its inlining ancestors, nothing else.
584
- pub fn invalidate_components(&mut self, keys: &std::collections::HashSet<String>) -> usize {
585
- let before = self.component_cache.len();
586
- self.component_cache.retain(|key, _| !keys.contains(key));
587
- before - self.component_cache.len()
588
- }
589
-
590
- /// Number of components currently held in the inlining cache.
591
- pub fn cached_component_count(&self) -> usize {
592
- self.component_cache.len()
593
- }
594
-
595
- /// Take over another compiler's warm component cache, leaving it empty.
596
- /// The watch loop uses this to carry the initial full-compile cache into the
597
- /// incremental compiler, so even the first edit reuses everything unchanged
598
- /// instead of re-fetching the whole tree.
599
- pub fn adopt_component_cache(&mut self, other: &mut Compiler) {
600
- self.component_cache = std::mem::take(&mut other.component_cache);
601
- }
602
-
603
- pub fn compile(&mut self) -> Result<CompileStats, CompileError> {
604
- let mut stats = CompileStats {
605
- files_compiled: 0,
606
- files_copied: 0,
607
- internal_components_unique: 0,
608
- external_components_unique: 0,
609
- internal_components_total: 0,
610
- external_components_total: 0,
611
- compile_time_ms: 0.0,
612
- copy_time_ms: 0.0,
613
- components_time_ms: 0.0,
614
- validation_time_ms: None,
615
- node_modules_time_ms: None,
616
- package_manager: None,
617
- node_modules_copied_as_is: false,
618
- components_as_is: self.config.components_as_is,
619
- };
620
-
621
- // Validate source exists
622
- if !self.config.source.exists() {
623
- return Err(CompileError::SourceNotFound(
624
- self.config.source.display().to_string(),
625
- ));
626
- }
627
-
628
- // Load components for HTML compilation
629
- let mut parser = HtmlParser::new(self.config.components_path());
630
- parser.load_elements()?;
631
-
632
- // Validate component names don't conflict with reserved elements
633
- self.validate_component_names()?;
634
-
635
- // Fetch and cache all components for inlining (when not components_as_is)
636
- // This is separate from validation - we need component content for inlining regardless of validate flag
637
- if !self.config.components_as_is {
638
- let fetch_start = Instant::now();
639
- if self.verbose {
640
- println!("\nFetching components for inlining...");
641
- }
642
- self.fetch_all_components(&parser)?;
643
- stats.components_time_ms = fetch_start.elapsed().as_secs_f64() * 1000.0;
644
- if self.verbose {
645
- println!(" All components fetched successfully");
646
- }
647
- }
648
-
649
- // Validate HTML syntax (always runs)
650
- let validation_start = Instant::now();
651
- if self.verbose {
652
- println!("\nValidating HTML syntax...");
653
- }
654
- self.validate_html_syntax()?;
655
- stats.validation_time_ms = Some(validation_start.elapsed().as_secs_f64() * 1000.0);
656
- if self.verbose {
657
- println!(" HTML validation completed successfully");
658
- }
659
-
660
- // Clean output directory if not in no-clean mode
661
- if !self.config.no_clean && self.config.output.exists() {
662
- if self.verbose {
663
- println!(" Cleaning output directory: {}", self.config.output.display());
664
- }
665
- fs::remove_dir_all(&self.config.output).map_err(|e| {
666
- CompileError::WriteError {
667
- path: self.config.output.display().to_string(),
668
- source: e,
669
- }
670
- })?;
671
- }
672
-
673
- // Create output directory
674
- if !self.config.output.exists() {
675
- fs::create_dir_all(&self.config.output).map_err(|_| {
676
- CompileError::CreateDirError(self.config.output.display().to_string())
677
- })?;
678
- if self.verbose {
679
- println!(" Created output directory: {}", self.config.output.display());
680
- }
681
- }
682
-
683
- // Canonicalize both output and source paths for reliable comparison
684
- let canonical_output = self.config.output.canonicalize()
685
- .unwrap_or_else(|_| self.config.output.clone());
686
- let canonical_source = self.config.source.canonicalize()
687
- .unwrap_or_else(|_| self.config.source.clone());
688
-
689
- // Track compile and copy times separately
690
- let compile_start = Instant::now();
691
-
692
- // Process all HTML files in source (excluding components directory)
693
- self.process_directory_html_only(&canonical_source, &parser, "", &canonical_output, &canonical_source, &mut stats)?;
694
-
695
- // Process source directory for assets (CSS, JS, images, etc.)
696
- self.process_directory_assets_only(&canonical_source, "", &canonical_output, &canonical_source, &mut stats)?;
697
-
698
- let process_time = compile_start.elapsed();
699
-
700
- // Print verbose output after processing (components first, then HTML, then copied)
701
- if let Some(ref logger) = self.logger {
702
- logger.print_components();
703
- logger.print_compiled();
704
- logger.print_copied();
705
- }
706
-
707
- // Calculate unique components after processing all files
708
- let internal_unique = self.unique_components.iter()
709
- .filter(|src| !src.starts_with("http://") && !src.starts_with("https://"))
710
- .count();
711
- let external_unique = self.unique_components.iter()
712
- .filter(|src| src.starts_with("http://") || src.starts_with("https://"))
713
- .count();
714
-
715
- stats.internal_components_unique = internal_unique;
716
- stats.external_components_unique = external_unique;
717
-
718
- // Calculate times based on counts (rough estimate)
719
- // Component operations are part of compile time but tracked separately for user visibility
720
- let total_files = stats.files_compiled + stats.files_copied;
721
- let total_components = stats.internal_components_total + stats.external_components_total;
722
-
723
- if total_files > 0 {
724
- let total_ms = process_time.as_secs_f64() * 1000.0;
725
-
726
- if total_components > 0 {
727
- // Component processing is slowest, compile is medium, copy is fastest
728
- // Rough weights: component=3, compile=2, copy=1
729
- let total_weight = (total_components * 3 + stats.files_compiled * 2 + stats.files_copied) as f64;
730
- stats.components_time_ms = (total_ms * (total_components * 3) as f64) / total_weight;
731
- stats.compile_time_ms = (total_ms * (stats.files_compiled * 2) as f64) / total_weight;
732
- stats.copy_time_ms = (total_ms * stats.files_copied as f64) / total_weight;
733
- } else {
734
- // No component operations, just split between compile and copy
735
- stats.compile_time_ms = (total_ms * stats.files_compiled as f64) / total_files as f64;
736
- stats.copy_time_ms = (total_ms * stats.files_copied as f64) / total_files as f64;
737
- }
738
- }
739
-
740
- // Handle node_modules after successful compilation
741
- if self.config.working_dir.join("node_modules").exists() {
742
- if self.config.node_modules_as_is {
743
- // Copy node_modules as-is (no install, just copy)
744
- let nm_start = Instant::now();
745
- self.copy_node_modules_as_is()?;
746
- stats.node_modules_time_ms = Some(nm_start.elapsed().as_secs_f64() * 1000.0);
747
- stats.node_modules_copied_as_is = true;
748
-
749
- if self.verbose {
750
- println!("\n Copied node_modules as-is");
751
- }
752
- } else {
753
- // Use production-only strategy (runs install commands)
754
- let pkg_manager = detect_package_manager(&self.config.working_dir).ok();
755
- stats.package_manager = pkg_manager.clone();
756
-
757
- let nm_start = Instant::now();
758
- self.copy_production_node_modules()?;
759
- stats.node_modules_time_ms = Some(nm_start.elapsed().as_secs_f64() * 1000.0);
760
-
761
- if self.verbose {
762
- println!(" Copied production dependencies");
763
- }
764
- }
765
- }
766
-
767
- Ok(stats)
768
- }
769
-
770
- /// Compile specific HTML files (incremental compilation for watch mode)
771
- pub fn compile_specific_html_files(
772
- &mut self,
773
- files: &[PathBuf],
774
- parser: &HtmlParser,
775
- ) -> Result<CompileStats, CompileError> {
776
- let mut stats = CompileStats {
777
- files_compiled: 0,
778
- files_copied: 0,
779
- internal_components_unique: 0,
780
- external_components_unique: 0,
781
- internal_components_total: 0,
782
- external_components_total: 0,
783
- compile_time_ms: 0.0,
784
- copy_time_ms: 0.0,
785
- components_time_ms: 0.0,
786
- validation_time_ms: None,
787
- node_modules_time_ms: None,
788
- package_manager: None,
789
- node_modules_copied_as_is: false,
790
- components_as_is: self.config.components_as_is,
791
- };
792
-
793
- let start = Instant::now();
794
-
795
- // Pre-fetch components needed by these specific files (only if not components_as_is)
796
- if !self.config.components_as_is {
797
- let fetch_start = Instant::now();
798
- self.fetch_components_for_files(files, parser)?;
799
- stats.components_time_ms = fetch_start.elapsed().as_secs_f64() * 1000.0;
800
- }
801
-
802
- for file_path in files {
803
- // Validate HTML before compiling (same as full compile path)
804
- let content = fs::read_to_string(file_path).map_err(|e| CompileError::ReadError {
805
- path: file_path.display().to_string(),
806
- source: e,
807
- })?;
808
- self.validate_html(&content, file_path)?;
809
-
810
- // Calculate relative path
811
- let relative_path = file_path
812
- .strip_prefix(&self.config.source)
813
- .map(|p| p.parent().unwrap_or(Path::new("")))
814
- .unwrap_or(Path::new(""))
815
- .to_str()
816
- .unwrap_or("");
817
-
818
- let (internal, external, component_srcs) = self.compile_html_file(file_path, parser, relative_path)?;
819
- stats.files_compiled += 1;
820
- stats.internal_components_total += internal;
821
- stats.external_components_total += external;
822
-
823
- // Track unique components
824
- for src in &component_srcs {
825
- self.unique_components.insert(src.clone());
826
- }
827
-
828
- if let Some(ref mut logger) = self.logger {
829
- logger.log(file_path, FileOperation::Compiled, &self.config.source);
830
- for src in component_srcs {
831
- logger.log_component_occurrence(src);
832
- }
833
- }
834
- }
835
-
836
- // Calculate unique components
837
- let internal_unique = self.unique_components.iter()
838
- .filter(|src| !src.starts_with("http://") && !src.starts_with("https://"))
839
- .count();
840
- let external_unique = self.unique_components.iter()
841
- .filter(|src| src.starts_with("http://") || src.starts_with("https://"))
842
- .count();
843
-
844
- stats.internal_components_unique = internal_unique;
845
- stats.external_components_unique = external_unique;
846
-
847
- // Calculate compile time (excluding component fetch time which is already tracked)
848
- let elapsed = start.elapsed().as_secs_f64() * 1000.0;
849
- stats.compile_time_ms = elapsed - stats.components_time_ms;
850
-
851
- Ok(stats)
852
- }
853
-
854
- /// Copy specific asset files (incremental compilation for watch mode)
855
- pub fn copy_specific_asset_files(
856
- &mut self,
857
- files: &[PathBuf],
858
- ) -> Result<CompileStats, CompileError> {
859
- let mut stats = CompileStats {
860
- files_compiled: 0,
861
- files_copied: 0,
862
- internal_components_unique: 0,
863
- external_components_unique: 0,
864
- internal_components_total: 0,
865
- external_components_total: 0,
866
- compile_time_ms: 0.0,
867
- copy_time_ms: 0.0,
868
- components_time_ms: 0.0,
869
- validation_time_ms: None,
870
- node_modules_time_ms: None,
871
- package_manager: None,
872
- node_modules_copied_as_is: false,
873
- components_as_is: self.config.components_as_is,
874
- };
875
-
876
- let start = Instant::now();
877
-
878
- for file_path in files {
879
- // Calculate relative path for preserving directory structure
880
- let relative_path = file_path
881
- .strip_prefix(&self.config.source)
882
- .map(|p| p.parent().unwrap_or(Path::new("")))
883
- .unwrap_or(Path::new(""))
884
- .to_str()
885
- .unwrap_or("");
886
-
887
- self.copy_file(file_path, relative_path)?;
888
- stats.files_copied += 1;
889
-
890
- if let Some(ref mut logger) = self.logger {
891
- logger.log(file_path, FileOperation::Copied, &self.config.source);
892
- }
893
- }
894
-
895
- let elapsed = start.elapsed().as_secs_f64() * 1000.0;
896
- stats.copy_time_ms = elapsed;
897
-
898
- Ok(stats)
899
- }
900
-
901
- /// Generate manifests for specific HTML files (incremental compilation for watch mode)
902
- pub fn generate_manifests_for_files(&self, files: &[PathBuf]) -> Result<ManifestStats, CompileError> {
903
- let start = Instant::now();
904
-
905
- // Mirror generate_manifests: process each page's manifest in parallel.
906
- // Manifest generation is the bulk of an incremental recompile (static
907
- // analysis + serialization per page), so a sequential loop over the
908
- // affected pages left most cores idle and dominated watch latency.
909
- let output_dir = self.config.output.clone();
910
- let source_root = self.config.source.clone();
911
- let verbose = self.verbose;
912
- let iterations_as_is = self.config.iterations_as_is;
913
- let components_as_is = self.config.components_as_is;
914
- let manifest_root = self.config.root.clone();
915
- // Resolve constant global-state keys once, shared read-only across pages.
916
- let global_constants = self.compute_global_constants();
917
-
918
- let compiled_html = &self.compiled_html;
919
- let results: Vec<bool> = files
920
- .par_iter()
921
- .map(|file_path| {
922
- let relative_path = match file_path.strip_prefix(&source_root).unwrap_or(file_path).to_str() {
923
- Some(r) => r,
924
- None => return false,
925
- };
926
-
927
- let output_path = output_dir.join(relative_path);
928
- if !output_path.exists() {
929
- return false;
930
- }
931
-
932
- // The HTML this compiler produced in memory is the source of
933
- // truth; the on-disk file may have been rewritten by another
934
- // process since we wrote it. Disk is only a fallback for pages
935
- // this compiler never compiled (e.g. no-clean leftovers).
936
- let disk_html;
937
- let html: &str = match compiled_html.get(&output_path) {
938
- Some(h) => h,
939
- None => {
940
- disk_html = match fs::read_to_string(&output_path) {
941
- Ok(h) => h,
942
- Err(_) => return false,
943
- };
944
- &disk_html
945
- }
946
- };
947
-
948
- match Self::generate_file_manifest(
949
- html,
950
- &output_path,
951
- &output_dir,
952
- relative_path,
953
- verbose,
954
- iterations_as_is,
955
- components_as_is,
956
- &source_root,
957
- manifest_root.as_deref(),
958
- &global_constants,
959
- ) {
960
- Ok(()) => true,
961
- Err(e) => {
962
- if verbose {
963
- eprintln!(" Skipped ({}): {}", e, relative_path);
964
- }
965
- false
966
- }
967
- }
968
- })
969
- .collect();
970
-
971
- let pages_processed = results.iter().filter(|&&ok| ok).count();
972
- let pages_skipped = results.len() - pages_processed;
973
-
974
- let total_time_ms = start.elapsed().as_millis() as f64;
975
-
976
- Ok(ManifestStats {
977
- pages_processed,
978
- pages_skipped,
979
- total_time_ms,
980
- })
981
- }
982
-
983
- /// Generate manifest for a single file
984
- fn generate_file_manifest(
985
- html: &str,
986
- html_path: &Path,
987
- output_dir: &Path,
988
- relative_path: &str,
989
- _verbose: bool,
990
- iterations_as_is: bool,
991
- components_as_is: bool,
992
- source_root: &Path,
993
- manifest_root: Option<&str>,
994
- global_constants: &Map<String, Value>,
995
- ) -> Result<(), String> {
996
- use crate::compiler::manifest_builder::ManifestBuilder;
997
- use crate::compiler::component_tagger::ComponentTagger;
998
- use crate::compiler::value_stamper::ValueStamper;
999
-
1000
- // Relocate whitespace-bearing name-bindings into a value-attribute BEFORE any
1001
- // html5ever round-trip splits them on their spaces (see name_binding_protect).
1002
- let protected = crate::compiler::name_binding_protect::protect(html);
1003
-
1004
- // Tag components with deterministic IDs and structure state
1005
- let tagged = ComponentTagger::tag_components(&protected, &source_root.to_path_buf())?;
1006
- let html = &tagged.html; // Use modified HTML with data-vibe-component-id attributes
1007
- let state = tagged.state;
1008
-
1009
- // Build manifest from pre-stamp HTML (both conditional branches present, @[...] markers intact)
1010
- // This ensures restoration.template has both branches so runtime can switch between them
1011
- let manifest_builder = ManifestBuilder::new();
1012
- let manifest = manifest_builder.build_from_html(html, &state, iterations_as_is)?;
1013
-
1014
- // Map the output-relative path to the served-URL path: drop the `root`
1015
- // dir (the folder served at /, e.g. `pages`) and collapse dynamic
1016
- // `$param` segments to a single `$` token. This lets the runtime resolve
1017
- // a manifest from a clean URL — `/armory` and `/the-arena/123` find
1018
- // `armory.html.manifest.js` and `the-arena/$.html.manifest.js`.
1019
- let manifest_rel = manifest_url_path(relative_path, manifest_root);
1020
-
1021
- // Write manifest
1022
- let manifest_path = output_dir
1023
- .join("vibe-hyperspeed")
1024
- .join(format!("{}.manifest.js", manifest_rel));
1025
-
1026
- // Ensure directory exists
1027
- if let Some(parent) = manifest_path.parent() {
1028
- fs::create_dir_all(parent)
1029
- .map_err(|e| format!("Failed to create manifest directory: {}", e))?;
1030
- }
1031
-
1032
- let manifest_json = serde_json::to_string(&manifest)
1033
- .map_err(|e| format!("Failed to serialize manifest: {}", e))?;
1034
-
1035
- let manifest_js = format!(
1036
- "// Pre-compiled manifest for /{}\n// Generated by Vibe compiler\n\nexport default {};\n",
1037
- manifest_rel,
1038
- manifest_json
1039
- );
1040
-
1041
- atomic_write(&manifest_path, &manifest_js)
1042
- .map_err(|e| format!("Failed to write manifest: {}", e))?;
1043
-
1044
- // Stamp the compiled HTML with initial state values for FOUC prevention:
1045
- // - @[...] bindings replaced with their initial values
1046
- // - Only the active conditional branch is kept (inactive branch stripped)
1047
- // - Iterations are pre-rendered with initial array data
1048
- // The manifest (written above) preserves BOTH branches so the runtime can
1049
- // restore them and switch between branches reactively.
1050
- let stamper = ValueStamper::with_constants(&state, components_as_is, global_constants)
1051
- .map_err(|e| format!("Failed to create value stamper: {}", e))?;
1052
- let stamped = stamper.stamp_html(html.to_string())
1053
- .map_err(|e| format!("Failed to stamp HTML: {}", e))?;
1054
-
1055
- atomic_write(html_path, &stamped)
1056
- .map_err(|e| format!("Failed to write stamped HTML: {}", e))?;
1057
-
1058
- Ok(())
1059
- }
1060
-
1061
- /// Resolve the global `$` state keys that reassignment analysis proves are
1062
- /// compile-time constants, mapped to their resolved values — the only globals
1063
- /// safe to value-stamp into pre-rendered HTML. Scans every JS source under the
1064
- /// project source root (`.js` modules, inline `<script>` bodies, and `on*`
1065
- /// handler bodies), skipping the compiled output and the configured skips.
1066
- /// Returns an empty map on any doubt (see reassignment_analyzer soundness).
1067
- fn compute_global_constants(&self) -> Map<String, Value> {
1068
- use crate::compiler::js_analyzer::JsAnalyzer;
1069
- use crate::compiler::reassignment_analyzer::classify_constant_keys;
1070
-
1071
- let root = &self.config.source;
1072
- let output_canon = self.config.output.canonicalize().ok();
1073
-
1074
- let mut sources: Vec<String> = Vec::new();
1075
- let mut js_paths: Vec<PathBuf> = Vec::new();
1076
- collect_state_sources(
1077
- root,
1078
- output_canon.as_deref(),
1079
- &self.config.skip_files,
1080
- &mut sources,
1081
- &mut js_paths,
1082
- );
1083
-
1084
- // Resolve the global vibe()/state() initial state. Partial resolution
1085
- // keeps statically-resolvable keys (e.g. `version: VERSION`) and drops
1086
- // runtime-only ones (`settings: loadLocalStorage(...)`), which is exactly
1087
- // the set we could ever stamp.
1088
- let mut global_state: Map<String, Value> = Map::new();
1089
- for path in &js_paths {
1090
- let code = match fs::read_to_string(path) {
1091
- Ok(c) => c,
1092
- Err(_) => continue,
1093
- };
1094
- if !(code.contains("vibe(") || code.contains("state(")) {
1095
- continue;
1096
- }
1097
- let mut analyzer = JsAnalyzer::new(root.clone());
1098
- if let Some(Value::Object(state)) = analyzer.extract_state(&code) {
1099
- for (key, value) in state {
1100
- global_state.insert(key, value);
1101
- }
1102
- }
1103
- }
1104
- if global_state.is_empty() {
1105
- return Map::new();
1106
- }
1107
-
1108
- let constant_keys = classify_constant_keys(&global_state, &sources);
1109
- global_state
1110
- .into_iter()
1111
- .filter(|(key, _)| constant_keys.contains(key))
1112
- .collect()
1113
- }
1114
-
1115
- /// Generate manifests (called separately from main.rs if needed)
1116
- pub fn generate_manifests(&self) -> Result<ManifestStats, CompileError> {
1117
-
1118
- let start = Instant::now();
1119
- let mut pages_processed = 0;
1120
- let mut pages_skipped = 0;
1121
-
1122
- if self.verbose {
1123
- println!("\nGenerating manifests (static analysis)");
1124
- }
1125
-
1126
- // Find all HTML files in compiled output (excluding components directory)
1127
- let html_files = self.find_all_html_files(&self.config.output)?;
1128
-
1129
- // Process manifests in parallel
1130
- let output_dir = self.config.output.clone();
1131
- let source_root = self.config.source.clone();
1132
- let verbose = self.verbose;
1133
- let iterations_as_is = self.config.iterations_as_is;
1134
- let components_as_is = self.config.components_as_is;
1135
- let manifest_root = self.config.root.clone();
1136
- // Resolve which global state keys are constant once, then share across all
1137
- // pages (read-only; `&Map` is Sync so the parallel map can borrow it).
1138
- let global_constants = self.compute_global_constants();
1139
-
1140
- let compiled_html = &self.compiled_html;
1141
- let results: Vec<_> = html_files
1142
- .par_iter()
1143
- .map(|html_path| {
1144
- let relative_path = html_path.strip_prefix(&output_dir)
1145
- .unwrap()
1146
- .to_str()
1147
- .unwrap();
1148
-
1149
- // Prefer the HTML this compiler produced in memory — the disk
1150
- // copy may have been rewritten by another process since. Disk
1151
- // is only a fallback for pages this compiler never compiled
1152
- // (e.g. no-clean leftovers from an earlier run).
1153
- let disk_html;
1154
- let html: &str = match compiled_html.get(html_path.as_path()) {
1155
- Some(h) => h,
1156
- None => {
1157
- disk_html = match fs::read_to_string(html_path) {
1158
- Ok(h) => h,
1159
- Err(_) => return (false, Some(relative_path.to_string())),
1160
- };
1161
- &disk_html
1162
- }
1163
- };
1164
-
1165
- // Try to generate manifest for this file (skip on error)
1166
- 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) {
1167
- Ok(()) => (true, None),
1168
- Err(e) => {
1169
- if verbose {
1170
- eprintln!(" Skipped ({}): {}", e, relative_path);
1171
- }
1172
- (false, Some(relative_path.to_string()))
1173
- }
1174
- }
1175
- })
1176
- .collect();
1177
-
1178
- // Count results
1179
- for (success, _) in results {
1180
- if success {
1181
- pages_processed += 1;
1182
- } else {
1183
- pages_skipped += 1;
1184
- }
1185
- }
1186
-
1187
- let total_time_ms = start.elapsed().as_millis() as f64;
1188
-
1189
- Ok(ManifestStats {
1190
- pages_processed,
1191
- pages_skipped,
1192
- total_time_ms,
1193
- })
1194
- }
1195
-
1196
- /// Process directory for HTML files only (used for pages directory)
1197
- fn process_directory_html_only(
1198
- &mut self,
1199
- dir: &Path,
1200
- parser: &HtmlParser,
1201
- relative_path: &str,
1202
- canonical_output: &Path,
1203
- canonical_source: &Path,
1204
- stats: &mut CompileStats,
1205
- ) -> Result<(), CompileError> {
1206
- let entries = fs::read_dir(dir).map_err(|e| CompileError::ReadError {
1207
- path: dir.display().to_string(),
1208
- source: e,
1209
- })?;
1210
-
1211
- for entry in entries.flatten() {
1212
- let path = entry.path();
1213
- let file_name = path.file_name().unwrap().to_str().unwrap();
1214
-
1215
- if path.is_dir() {
1216
- // Skip output directory (dynamically check, not hardcoded "compiled")
1217
- let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone());
1218
- if canonical_path == *canonical_output || canonical_path.starts_with(canonical_output) {
1219
- continue;
1220
- }
1221
-
1222
- // Skip special directories
1223
- if should_skip_path(&path, file_name, &self.config.skip_files) {
1224
- continue;
1225
- }
1226
-
1227
- // Skip components directory
1228
- if file_name == self.config.components {
1229
- continue;
1230
- }
1231
-
1232
- // Recurse into subdirectory
1233
- let new_relative = if relative_path.is_empty() {
1234
- file_name.to_string()
1235
- } else {
1236
- format!("{}/{}", relative_path, file_name)
1237
- };
1238
-
1239
- self.process_directory_html_only(&path, parser, &new_relative, canonical_output, canonical_source, stats)?;
1240
- } else if path.extension().and_then(|e| e.to_str()) == Some("html") {
1241
- let (internal, external, component_srcs) = self.compile_html_file(&path, parser, relative_path)?;
1242
- stats.files_compiled += 1;
1243
- stats.internal_components_total += internal;
1244
- stats.external_components_total += external;
1245
-
1246
- // Track unique components
1247
- for src in &component_srcs {
1248
- self.unique_components.insert(src.clone());
1249
- }
1250
-
1251
- // Build component relationships and count occurrences before borrowing logger
1252
- let (component_relationships, all_srcs) = if self.logger.is_some() && !component_srcs.is_empty() {
1253
- let relationships = self.build_component_relationships(&component_srcs);
1254
- (Some(relationships), Some(component_srcs))
1255
- } else {
1256
- (None, None)
1257
- };
1258
-
1259
- if let Some(ref mut logger) = self.logger {
1260
- logger.log(&path, FileOperation::Compiled, canonical_source);
1261
-
1262
- // Log each component occurrence (for counting)
1263
- if let Some(all_srcs) = all_srcs {
1264
- for src in all_srcs {
1265
- logger.log_component_occurrence(src);
1266
- }
1267
- }
1268
-
1269
- // Log unique relationships (for tree structure)
1270
- if let Some(relationships) = component_relationships {
1271
- for (src, children) in relationships {
1272
- logger.log_component_children(src, children);
1273
- }
1274
- }
1275
- }
1276
- }
1277
- }
1278
-
1279
- Ok(())
1280
- }
1281
-
1282
- /// Process directory for assets only (CSS, JS, images, etc.) - skip HTML
1283
- fn process_directory_assets_only(
1284
- &mut self,
1285
- dir: &Path,
1286
- relative_path: &str,
1287
- canonical_output: &Path,
1288
- canonical_source: &Path,
1289
- stats: &mut CompileStats,
1290
- ) -> Result<(), CompileError> {
1291
- let entries = fs::read_dir(dir).map_err(|e| CompileError::ReadError {
1292
- path: dir.display().to_string(),
1293
- source: e,
1294
- })?;
1295
-
1296
- for entry in entries.flatten() {
1297
- let path = entry.path();
1298
- let file_name = path.file_name().unwrap().to_str().unwrap();
1299
-
1300
- if path.is_dir() {
1301
- // Skip output directory (dynamically check, not hardcoded "compiled")
1302
- let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone());
1303
- if canonical_path == *canonical_output || canonical_path.starts_with(canonical_output) {
1304
- continue;
1305
- }
1306
-
1307
- // Skip special directories
1308
- if should_skip_path(&path, file_name, &self.config.skip_files) {
1309
- continue;
1310
- }
1311
-
1312
- // Skip node_modules (handled separately)
1313
- if file_name == "node_modules" {
1314
- continue;
1315
- }
1316
-
1317
- // The components directory is always mirrored to the output. With
1318
- // components_as_is every component is loaded at runtime; otherwise
1319
- // most are inlined at build time, but iter-prop each-root components
1320
- // are deliberately left as runtime `<component src>` tags (see
1321
- // is_iter_prop_root in parser/html.rs) and the runtime fetches their
1322
- // source from here — exactly as it does in non-compiled mode.
1323
- if file_name == self.config.components {
1324
- let new_relative = if relative_path.is_empty() {
1325
- file_name.to_string()
1326
- } else {
1327
- format!("{}/{}", relative_path, file_name)
1328
- };
1329
- self.copy_directory(&path, &new_relative, canonical_source, stats)?;
1330
- // Skip further processing (don't recurse into components)
1331
- continue;
1332
- }
1333
-
1334
- // Recurse into subdirectory
1335
- let new_relative = if relative_path.is_empty() {
1336
- file_name.to_string()
1337
- } else {
1338
- format!("{}/{}", relative_path, file_name)
1339
- };
1340
-
1341
- self.process_directory_assets_only(&path, &new_relative, canonical_output, canonical_source, stats)?;
1342
- } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
1343
- // Skip files matching skip patterns
1344
- if should_skip_path(&path, file_name, &self.config.skip_files) {
1345
- continue;
1346
- }
1347
-
1348
- // Only copy non-HTML assets
1349
- if ext != "html" && MIRROR_EXTENSIONS.contains(&ext) {
1350
- self.copy_file(&path, relative_path)?;
1351
- stats.files_copied += 1;
1352
- if let Some(ref mut logger) = self.logger {
1353
- logger.log(&path, FileOperation::Copied, canonical_source);
1354
- }
1355
- }
1356
- }
1357
- }
1358
-
1359
- Ok(())
1360
- }
1361
-
1362
- fn compile_html_file(
1363
- &mut self,
1364
- path: &Path,
1365
- parser: &HtmlParser,
1366
- relative_path: &str,
1367
- ) -> Result<(usize, usize, Vec<String>), CompileError> {
1368
- let content = fs::read_to_string(path).map_err(|e| CompileError::ReadError {
1369
- path: path.display().to_string(),
1370
- source: e,
1371
- })?;
1372
-
1373
- // Count <component src="..."> instances before processing (only if not components_as_is)
1374
- let (internal_count, external_count, component_srcs) = if !self.config.components_as_is {
1375
- self.extract_components(&content)
1376
- } else {
1377
- (0, 0, Vec::new())
1378
- };
1379
-
1380
- // Compile: transform custom tags to <component>, inline if needed, transform custom elements
1381
- let processed = parser.process_html_with_cache(
1382
- &content,
1383
- self.config.elements_as_is,
1384
- &self.config.reserved_elements,
1385
- self.config.components_as_is,
1386
- &self.config.components,
1387
- &self.component_cache,
1388
- );
1389
-
1390
- // Minify if requested
1391
- let mut output = if self.config.minify {
1392
- minify_html(&processed)
1393
- } else {
1394
- processed
1395
- };
1396
-
1397
- // Remove FOUC prevention unless fouc_as_is is enabled
1398
- if !self.config.fouc_as_is {
1399
- output = remove_fouc_prevention(output);
1400
- }
1401
-
1402
- // Write to output
1403
- let output_path = self.get_output_path(path, relative_path)?;
1404
- atomic_write(&output_path, &output).map_err(|e| CompileError::WriteError {
1405
- path: output_path.display().to_string(),
1406
- source: e,
1407
- })?;
1408
-
1409
- // Keep the exact bytes for this pass's manifest generation.
1410
- self.compiled_html.insert(output_path, output);
1411
-
1412
- // Return component counts and src list
1413
- Ok((internal_count, external_count, component_srcs))
1414
- }
1415
-
1416
- fn extract_components(&self, content: &str) -> (usize, usize, Vec<String>) {
1417
- let mut internal = 0;
1418
- let mut external = 0;
1419
- let mut srcs = Vec::new();
1420
-
1421
- // Simple regex-based extraction of <component src="..."> tags
1422
- let re = Regex::new(r#"<component[^>]+src\s*=\s*["']([^"']+)["']"#).unwrap();
1423
- for cap in re.captures_iter(content) {
1424
- if let Some(src) = cap.get(1) {
1425
- let src_str = src.as_str().to_string();
1426
- if src_str.starts_with("http://") || src_str.starts_with("https://") {
1427
- external += 1;
1428
- } else {
1429
- internal += 1;
1430
- }
1431
- srcs.push(src_str);
1432
- }
1433
- }
1434
-
1435
- (internal, external, srcs)
1436
- }
1437
-
1438
- fn build_component_relationships(&mut self, component_srcs: &[String]) -> Vec<(String, Vec<String>)> {
1439
- let mut relationships = Vec::new();
1440
- let mut visited = HashSet::new();
1441
-
1442
- for src in component_srcs {
1443
- self.collect_component_children(src, &mut relationships, &mut visited);
1444
- }
1445
-
1446
- relationships
1447
- }
1448
-
1449
- fn collect_component_children(&mut self, component_src: &str, relationships: &mut Vec<(String, Vec<String>)>, visited: &mut HashSet<String>) {
1450
- if visited.contains(component_src) {
1451
- return;
1452
- }
1453
- visited.insert(component_src.to_string());
1454
-
1455
- // Read component content
1456
- let content = if component_src.starts_with("http://") || component_src.starts_with("https://") {
1457
- match self.fetch_external_component_raw(component_src) {
1458
- Ok(c) => c,
1459
- Err(_) => return,
1460
- }
1461
- } else {
1462
- match self.resolve_component_path(component_src) {
1463
- Ok(path) => match fs::read_to_string(&path) {
1464
- Ok(c) => c,
1465
- Err(_) => return,
1466
- },
1467
- Err(_) => return,
1468
- }
1469
- };
1470
-
1471
- // Extract immediate children
1472
- let children = self.extract_component_srcs(&content);
1473
-
1474
- // Add this component and its children to relationships
1475
- relationships.push((component_src.to_string(), children.clone()));
1476
-
1477
- // Recursively collect children's relationships
1478
- for child in children {
1479
- self.collect_component_children(&child, relationships, visited);
1480
- }
1481
- }
1482
-
1483
-
1484
- /// MIRROR_MODE: Copy file to output, preserving relative path
1485
- fn copy_file(&self, path: &Path, relative_path: &str) -> Result<(), CompileError> {
1486
- let output_path = self.get_output_path(path, relative_path)?;
1487
-
1488
- fs::copy(path, &output_path).map_err(|e| CompileError::WriteError {
1489
- path: output_path.display().to_string(),
1490
- source: e,
1491
- })?;
1492
-
1493
- Ok(())
1494
- }
1495
-
1496
- /// Re-mirror specific source files under the components directory into the
1497
- /// output. The full build always mirrors components/ verbatim (see
1498
- /// process_directory_assets_only): runtime-fetched components —
1499
- /// `<component src="@[page.src]">` targets, iter-prop each-roots,
1500
- /// components_as_is — are served from that mirror at request time. Watch
1501
- /// mode calls this for every changed component so the mirror tracks edits
1502
- /// and deletions even when no page inlines the component (zero graph
1503
- /// dependents). Writes are atomic (same tmp+rename as compiled pages) so a
1504
- /// runtime fetch mid-copy never sees a torn file. Returns how many files
1505
- /// were copied.
1506
- pub fn mirror_component_files(&self, files: &[PathBuf]) -> Result<usize, CompileError> {
1507
- let mut copied = 0;
1508
- for path in files {
1509
- let relative = path.strip_prefix(&self.config.source).unwrap_or(path);
1510
- let output_path = self.config.output.join(relative);
1511
- if path.exists() {
1512
- if let Some(parent) = output_path.parent() {
1513
- fs::create_dir_all(parent).map_err(|e| CompileError::WriteError {
1514
- path: parent.display().to_string(),
1515
- source: e,
1516
- })?;
1517
- }
1518
- let contents = fs::read_to_string(path).map_err(|e| CompileError::ReadError {
1519
- path: path.display().to_string(),
1520
- source: e,
1521
- })?;
1522
- atomic_write(&output_path, &contents).map_err(|e| CompileError::WriteError {
1523
- path: output_path.display().to_string(),
1524
- source: e,
1525
- })?;
1526
- copied += 1;
1527
- } else if output_path.exists() {
1528
- fs::remove_file(&output_path).map_err(|e| CompileError::WriteError {
1529
- path: output_path.display().to_string(),
1530
- source: e,
1531
- })?;
1532
- }
1533
- }
1534
- Ok(copied)
1535
- }
1536
-
1537
- /// Fetch components only for specific files (used in incremental compilation)
1538
- fn fetch_components_for_files(&mut self, files: &[PathBuf], parser: &HtmlParser) -> Result<(), CompileError> {
1539
- for html_file in files {
1540
- let content = match fs::read_to_string(html_file) {
1541
- Ok(c) => c,
1542
- Err(e) => {
1543
- if self.verbose {
1544
- eprintln!(" Warning: Failed to read {}: {}", html_file.display(), e);
1545
- }
1546
- continue;
1547
- }
1548
- };
1549
-
1550
- // Transform custom tags to <component> tags
1551
- let transformed = parser.process_html(
1552
- &content,
1553
- self.config.elements_as_is,
1554
- &self.config.reserved_elements,
1555
- true, // components_as_is (don't inline, just transform)
1556
- &self.config.components,
1557
- );
1558
-
1559
- // Extract and fetch components recursively
1560
- let components = self.extract_component_srcs(&transformed);
1561
- for component_src in components {
1562
- let mut visiting = std::collections::HashSet::new();
1563
- self.fetch_component_recursive(&component_src, parser, &mut visiting);
1564
- }
1565
- }
1566
-
1567
- Ok(())
1568
- }
1569
-
1570
- /// Fetch and cache all components for inlining (separate from validation)
1571
- /// This recursively fetches all components (external and internal) and populates component_cache
1572
- fn fetch_all_components(&mut self, parser: &HtmlParser) -> Result<(), CompileError> {
1573
- // Find all HTML files in source directory (need to scan pages to find external component references)
1574
- let html_files = self.find_all_html_files(&self.config.source)?;
1575
-
1576
- // Scan each HTML file for components
1577
- for html_file in html_files {
1578
- let content = match fs::read_to_string(&html_file) {
1579
- Ok(c) => c,
1580
- Err(e) => {
1581
- if self.verbose {
1582
- eprintln!(" Warning: Failed to read {}: {}", html_file.display(), e);
1583
- }
1584
- continue;
1585
- }
1586
- };
1587
-
1588
- // Transform custom tags to <component> tags
1589
- let transformed = parser.process_html(
1590
- &content,
1591
- self.config.elements_as_is,
1592
- &self.config.reserved_elements,
1593
- true, // components_as_is (don't inline, just transform)
1594
- &self.config.components,
1595
- );
1596
-
1597
- // Extract and fetch components recursively
1598
- let components = self.extract_component_srcs(&transformed);
1599
- for component_src in components {
1600
- let mut visiting = std::collections::HashSet::new();
1601
- self.fetch_component_recursive(&component_src, parser, &mut visiting);
1602
- }
1603
- }
1604
-
1605
- Ok(())
1606
- }
1607
-
1608
- /// Recursively fetch a component and its nested components (for inlining).
1609
- /// Returns the fully inlined content (all nested components resolved).
1610
- ///
1611
- /// `visiting` tracks the chain of components currently being fetched. A
1612
- /// `<component src>` that points back into the chain is a cycle (direct
1613
- /// self-reference, or A→B→A) — those are valid runtime patterns (recursive
1614
- /// tree components bounded by data depth) but would expand infinitely at
1615
- /// compile time. Cycle references are escaped to `data-vibe-recursive-src`
1616
- /// in the cached content so the inliner's `src=` regex won't keep matching
1617
- /// them; `process_html_with_cache` restores the attribute name at the end
1618
- /// of compilation so the runtime sees a normal `<component src>` tag.
1619
- fn fetch_component_recursive(
1620
- &mut self,
1621
- component_src: &str,
1622
- parser: &HtmlParser,
1623
- visiting: &mut std::collections::HashSet<String>,
1624
- ) -> Option<String> {
1625
- // Normalize path: ensure it starts with / (unless it's a URL)
1626
- let normalized_src = if component_src.starts_with("http://") || component_src.starts_with("https://") {
1627
- component_src.to_string()
1628
- } else {
1629
- let without_prefix = component_src.trim_start_matches("./");
1630
- if without_prefix.starts_with('/') {
1631
- without_prefix.to_string()
1632
- } else {
1633
- format!("/{}", without_prefix)
1634
- }
1635
- };
1636
-
1637
- // Return cached if already fetched and fully resolved
1638
- if let Some(cached) = self.component_cache.get(&normalized_src) {
1639
- return Some(cached.clone());
1640
- }
1641
-
1642
- // Cycle: this component is already higher in the fetch chain. Don't
1643
- // recurse — the caller will leave the `<component src>` tag as-is and
1644
- // the runtime handles the recursion with real data bounds.
1645
- if visiting.contains(&normalized_src) {
1646
- return None;
1647
- }
1648
-
1649
- // Fetch external or read internal component (raw content)
1650
- let content = if normalized_src.starts_with("http://") || normalized_src.starts_with("https://") {
1651
- match self.fetch_external_component_raw(&normalized_src) {
1652
- Ok(c) => c,
1653
- Err(e) => {
1654
- if self.verbose {
1655
- eprintln!(" Warning: Failed to fetch {}: {}", normalized_src, e);
1656
- }
1657
- return None;
1658
- }
1659
- }
1660
- } else {
1661
- match self.resolve_component_path(&normalized_src) {
1662
- Ok(path) => match fs::read_to_string(&path) {
1663
- Ok(c) => c,
1664
- Err(e) => {
1665
- if self.verbose {
1666
- eprintln!(" Warning: Failed to read component {}: {}", normalized_src, e);
1667
- }
1668
- return None;
1669
- }
1670
- },
1671
- Err(e) => {
1672
- if self.verbose {
1673
- eprintln!(" Warning: {}", e);
1674
- }
1675
- return None;
1676
- }
1677
- }
1678
- };
1679
-
1680
- // Transform custom tags in component (but keep component tags as-is for now)
1681
- let mut transformed = parser.process_html(
1682
- &content,
1683
- self.config.elements_as_is, // respect global config
1684
- &self.config.reserved_elements.clone(), // respect global config
1685
- true, // components_as_is = true (keep <component> tags for now so we can recursively resolve them)
1686
- &self.config.components,
1687
- );
1688
-
1689
- // Mark this component as in-flight before recursing into its nested
1690
- // refs. Cycles (direct self-reference or A→B→A chains) are detected
1691
- // by the visiting check at the top of this function.
1692
- visiting.insert(normalized_src.clone());
1693
-
1694
- // Recursively fetch and inline all nested components
1695
- let nested = self.extract_component_srcs(&transformed);
1696
- for nested_src in nested {
1697
- if let Some(nested_content) = self.fetch_component_recursive(&nested_src, parser, visiting) {
1698
- // Inline this nested component into the current component
1699
- transformed = parser.inline_single_component(&transformed, &nested_src, &nested_content);
1700
- }
1701
- }
1702
-
1703
- visiting.remove(&normalized_src);
1704
-
1705
- // Any `<component src>` still in `transformed` whose target is in the
1706
- // visiting chain (a cycle) didn't get inlined. Escape its `src=` so
1707
- // `inline_component_elements` won't keep re-expanding it forever when
1708
- // a page inlines this cached content. `process_html_with_cache`
1709
- // restores the attribute to plain `src=` at the end of compilation so
1710
- // the runtime fetches it normally.
1711
- for ancestor in visiting.iter() {
1712
- transformed = escape_recursive_src(&transformed, ancestor);
1713
- }
1714
- // Also escape direct self-references — a component pointing at itself
1715
- // never made it into `visiting` (the cycle check short-circuited
1716
- // before insertion), so handle it explicitly.
1717
- transformed = escape_recursive_src(&transformed, &normalized_src);
1718
-
1719
- // Cache the fully resolved content
1720
- self.component_cache.insert(normalized_src.clone(), transformed.clone());
1721
-
1722
- Some(transformed)
1723
- }
1724
-
1725
- /// Validate that component filenames don't match reserved elements
1726
- fn validate_component_names(&self) -> Result<(), CompileError> {
1727
- let components_dir = self.config.components_path();
1728
-
1729
- if !components_dir.exists() {
1730
- return Ok(()); // No components directory
1731
- }
1732
-
1733
- let component_files = self.find_all_html_files(&components_dir)?;
1734
-
1735
- for component_file in component_files {
1736
- let file_name = component_file
1737
- .file_stem()
1738
- .and_then(|s| s.to_str())
1739
- .unwrap_or("");
1740
-
1741
- // Check if filename (case-sensitive) matches any reserved element
1742
- if self.config.reserved_elements.contains(&file_name.to_string()) {
1743
- let relative_path = component_file
1744
- .strip_prefix(&self.config.source)
1745
- .unwrap_or(&component_file);
1746
-
1747
- return Err(CompileError::ReservedComponentName {
1748
- component_name: file_name.to_string(),
1749
- file_path: relative_path.display().to_string(),
1750
- });
1751
- }
1752
- }
1753
-
1754
- Ok(())
1755
- }
1756
-
1757
- /// Validate HTML syntax for all files (controlled by validate flag)
1758
- fn validate_html_syntax(&self) -> Result<(), CompileError> {
1759
- // Find all HTML files in source directory
1760
- let html_files = self.find_all_html_files(&self.config.source)?;
1761
-
1762
- for html_file in html_files {
1763
- let content = fs::read_to_string(&html_file).map_err(|e| CompileError::ReadError {
1764
- path: html_file.display().to_string(),
1765
- source: e,
1766
- })?;
1767
-
1768
- // Validate HTML syntax
1769
- self.validate_html(&content, &html_file)?;
1770
- }
1771
-
1772
- Ok(())
1773
- }
1774
-
1775
- /// Fetch external component without caching (returns raw content)
1776
- fn fetch_external_component_raw(&self, url: &str) -> Result<String, String> {
1777
- match ureq::get(url).call() {
1778
- Ok(response) => response.into_string().map_err(|e| format!("Failed to read response body: {}", e)),
1779
- Err(ureq::Error::Status(code, response)) => Err(format!("HTTP {} - {}", code, response.status_text().to_string())),
1780
- Err(e) => Err(format!("Failed to fetch: {}", e)),
1781
- }
1782
- }
1783
-
1784
- fn resolve_component_path(&self, component_src: &str) -> Result<PathBuf, String> {
1785
- // Normalize path (remove leading ./ or /)
1786
- let normalized = component_src.trim_start_matches("./").trim_start_matches('/');
1787
-
1788
- // Try relative to source root
1789
- let path = self.config.source.join(normalized);
1790
- if path.exists() {
1791
- return Ok(path);
1792
- }
1793
-
1794
- Err(format!("Component file not found: {}", component_src))
1795
- }
1796
-
1797
- fn extract_component_srcs(&self, content: &str) -> Vec<String> {
1798
- let mut srcs = Vec::new();
1799
- // Match both <component src="..."> and <div class="component" src="...">
1800
- // After elements_as_is=false transform, <component> becomes <div class="component">
1801
- let re = Regex::new(r#"(?:<component|<div\s+class="component")[^>]+src\s*=\s*["']([^"']+)["']"#).unwrap();
1802
- for cap in re.captures_iter(content) {
1803
- if let Some(src) = cap.get(1) {
1804
- srcs.push(src.as_str().to_string());
1805
- }
1806
- }
1807
- srcs
1808
- }
1809
-
1810
- fn find_all_html_files(&self, dir: &Path) -> Result<Vec<PathBuf>, CompileError> {
1811
- let mut html_files = Vec::new();
1812
- // Get output directory basename to skip nested directories with same name
1813
- let output_dir_name = self.config.output.file_name()
1814
- .and_then(|n| n.to_str())
1815
- .unwrap_or("");
1816
- self.find_html_files_recursive(dir, &mut html_files, output_dir_name)?;
1817
- Ok(html_files)
1818
- }
1819
-
1820
- fn find_html_files_recursive(&self, dir: &Path, files: &mut Vec<PathBuf>, output_dir_name: &str) -> Result<(), CompileError> {
1821
- if !dir.is_dir() {
1822
- return Ok(());
1823
- }
1824
-
1825
- let entries = match fs::read_dir(dir) {
1826
- Ok(e) => e,
1827
- Err(_) => return Ok(()), // Skip directories we can't read
1828
- };
1829
-
1830
- for entry in entries {
1831
- let entry = match entry {
1832
- Ok(e) => e,
1833
- Err(_) => continue,
1834
- };
1835
-
1836
- let path = entry.path();
1837
- let file_name = entry.file_name();
1838
- let file_name_str = file_name.to_string_lossy();
1839
-
1840
- // Skip specific directories/patterns (includes dotfiles via SKIP_FILES)
1841
- if should_skip_path(&path, &file_name_str, &self.config.skip_files) {
1842
- continue;
1843
- }
1844
-
1845
- // Skip components directory during initial scan (we'll validate them when referenced)
1846
- if file_name_str == self.config.components {
1847
- continue;
1848
- }
1849
-
1850
- // Skip nested directories with same name as output dir (from previous bad compilations)
1851
- if !output_dir_name.is_empty() && file_name_str == output_dir_name {
1852
- continue;
1853
- }
1854
-
1855
- if path.is_dir() {
1856
- self.find_html_files_recursive(&path, files, output_dir_name)?;
1857
- } else if path.extension().and_then(|e| e.to_str()) == Some("html") {
1858
- files.push(path);
1859
- }
1860
- }
1861
-
1862
- Ok(())
1863
- }
1864
-
1865
- fn get_output_path(&self, path: &Path, relative_path: &str) -> Result<PathBuf, CompileError> {
1866
- let file_name = path.file_name().unwrap();
1867
-
1868
- let output_path = if relative_path.is_empty() {
1869
- self.config.output.join(file_name)
1870
- } else {
1871
- let output_subdir = self.config.output.join(relative_path);
1872
- fs::create_dir_all(&output_subdir).map_err(|_| {
1873
- CompileError::CreateDirError(output_subdir.display().to_string())
1874
- })?;
1875
- output_subdir.join(file_name)
1876
- };
1877
-
1878
- Ok(output_path)
1879
- }
1880
-
1881
- fn validate_html(&self, content: &str, path: &Path) -> Result<(), CompileError> {
1882
- // Check 1: Unclosed attribute quotes
1883
- self.validate_quote_balance(content, path)?;
1884
-
1885
- // Check 2: Tag balance (unclosed elements break slot extraction)
1886
- self.validate_tag_balance(content, path)
1887
- }
1888
-
1889
- /// Quotes only matter inside tags and attribute values may span lines;
1890
- /// comments and raw content (script, style, pre) are skipped entirely.
1891
- fn validate_quote_balance(&self, content: &str, path: &Path) -> Result<(), CompileError> {
1892
- const RAW_CONTENT: &[&str] = &["script", "style", "pre"];
1893
-
1894
- let bytes = content.as_bytes();
1895
- let mut i = 0;
1896
- let mut line = 1;
1897
-
1898
- while i < bytes.len() {
1899
- if bytes[i] == b'\n' {
1900
- line += 1;
1901
- i += 1;
1902
- } else if bytes[i..].starts_with(b"<!--") {
1903
- match find_bytes(bytes, b"-->", i + 4) {
1904
- Some(end) => {
1905
- line += count_newlines(&bytes[i..end + 3]);
1906
- i = end + 3;
1907
- }
1908
- None => break,
1909
- }
1910
- } else if bytes[i] == b'<'
1911
- && i + 1 < bytes.len()
1912
- && (bytes[i + 1].is_ascii_alphabetic() || bytes[i + 1] == b'/' || bytes[i + 1] == b'!')
1913
- {
1914
- let is_closing = bytes[i + 1] == b'/';
1915
- let name_start = i + if is_closing { 2 } else { 1 };
1916
- let name_end = bytes[name_start..]
1917
- .iter()
1918
- .position(|b| !(b.is_ascii_alphanumeric() || *b == b'-'))
1919
- .map_or(bytes.len(), |p| name_start + p);
1920
- let name = content[name_start..name_end].to_ascii_lowercase();
1921
- i = name_end;
1922
-
1923
- let mut quote: Option<(u8, usize)> = None;
1924
- let mut self_closing = false;
1925
- while i < bytes.len() {
1926
- let b = bytes[i];
1927
- if b == b'\n' {
1928
- line += 1;
1929
- }
1930
- match quote {
1931
- Some((q, _)) if b == q => quote = None,
1932
- Some(_) => {}
1933
- None => match b {
1934
- b'"' | b'\'' => quote = Some((b, line)),
1935
- b'>' => {
1936
- self_closing = bytes[i - 1] == b'/';
1937
- break;
1938
- }
1939
- _ => {}
1940
- },
1941
- }
1942
- i += 1;
1943
- }
1944
-
1945
- if let Some((_, quote_line)) = quote {
1946
- return Err(CompileError::ValidationError {
1947
- file: path.display().to_string(),
1948
- line: quote_line,
1949
- message: "Unclosed quote".to_string(),
1950
- });
1951
- }
1952
- if i >= bytes.len() {
1953
- break;
1954
- }
1955
- i += 1;
1956
-
1957
- if !is_closing && !self_closing && RAW_CONTENT.contains(&name.as_str()) {
1958
- let close = format!("</{}", name);
1959
- match find_bytes_ci(bytes, close.as_bytes(), i) {
1960
- Some(pos) => {
1961
- line += count_newlines(&bytes[i..pos]);
1962
- i = pos;
1963
- }
1964
- None => break, // unterminated raw block — tag balance reports it
1965
- }
1966
- }
1967
- } else {
1968
- i += 1;
1969
- }
1970
- }
1971
-
1972
- Ok(())
1973
- }
1974
-
1975
- fn validate_tag_balance(&self, content: &str, path: &Path) -> Result<(), CompileError> {
1976
- const VOID_ELEMENTS: &[&str] = &[
1977
- "area", "base", "br", "col", "embed", "hr", "img", "input",
1978
- "link", "meta", "param", "source", "track", "wbr",
1979
- ];
1980
- // These are implicitly closed by the parser — don't require explicit close tags
1981
- const IMPLICIT_CLOSE: &[&str] = &[
1982
- "html", "head", "body", "p", "li", "dt", "dd", "option",
1983
- "optgroup", "tr", "td", "th", "thead", "tbody", "tfoot", "colgroup",
1984
- ];
1985
-
1986
- // Strip comments, raw content blocks, and @[...] bindings before tag scanning
1987
- let stripped = Self::strip_for_tag_validation(content);
1988
-
1989
- // Match any tag: opening, self-closing, or closing
1990
- let tag_re = Regex::new(r"</?([a-zA-Z][a-zA-Z0-9-]*)(?:\s[^>]*)?>").unwrap();
1991
-
1992
- // Count opens vs closes per tag name; track line of first open for error reporting
1993
- let mut counts: HashMap<String, (i32, usize)> = HashMap::new();
1994
- for cap in tag_re.captures_iter(&stripped) {
1995
- let full = cap.get(0).unwrap().as_str();
1996
- let name = cap[1].to_lowercase();
1997
- if VOID_ELEMENTS.contains(&name.as_str()) || IMPLICIT_CLOSE.contains(&name.as_str()) {
1998
- continue;
1999
- }
2000
- if full.starts_with("</") {
2001
- counts.entry(name).and_modify(|(c, _)| *c -= 1);
2002
- } else if full.ends_with("/>") {
2003
- // self-closing — no balance change
2004
- } else {
2005
- let offset = cap.get(0).unwrap().start();
2006
- let line = stripped[..offset].matches('\n').count() + 1;
2007
- let entry = counts.entry(name).or_insert((0, line));
2008
- entry.0 += 1;
2009
- }
2010
- }
2011
-
2012
- // Report the first unclosed tag (sorted by line for deterministic output)
2013
- let mut unclosed: Vec<(String, usize)> = counts.into_iter()
2014
- .filter(|(_, (count, _))| *count > 0)
2015
- .map(|(name, (_, line))| (name, line))
2016
- .collect();
2017
- unclosed.sort_by_key(|(_, line)| *line);
2018
-
2019
- if let Some((name, line)) = unclosed.first() {
2020
- return Err(CompileError::ValidationError {
2021
- file: path.display().to_string(),
2022
- line: *line,
2023
- message: format!("Unclosed tag <{}>", name),
2024
- });
2025
- }
2026
-
2027
- Ok(())
2028
- }
2029
-
2030
- fn strip_for_tag_validation(content: &str) -> String {
2031
- // Strip HTML comments <!-- ... --> (includes Vibe syntax: each, if, else, /if, /each)
2032
- let comment_re = Regex::new(r"(?s)<!--.*?-->").unwrap();
2033
- let s = comment_re.replace_all(content, "");
2034
- // Strip @[...] reactive bindings (can contain < and > characters)
2035
- let binding_re = Regex::new(r"@\[[^\]]*\]").unwrap();
2036
- let s = binding_re.replace_all(&s, "");
2037
- // Strip content inside <script>, <style>, <pre> to avoid parsing embedded code as HTML
2038
- let raw_re = Regex::new(r"(?si)(<(?:script|style|pre)(?:\s[^>]*)?>).*?(</(?:script|style|pre)>)").unwrap();
2039
- raw_re.replace_all(&s, "$1$2").into_owned()
2040
- }
2041
-
2042
- /// Copy an entire directory recursively
2043
- fn copy_directory(
2044
- &mut self,
2045
- dir: &Path,
2046
- relative_path: &str,
2047
- canonical_source: &Path,
2048
- stats: &mut CompileStats,
2049
- ) -> Result<(), CompileError> {
2050
- let entries = fs::read_dir(dir).map_err(|e| CompileError::ReadError {
2051
- path: dir.display().to_string(),
2052
- source: e,
2053
- })?;
2054
-
2055
- for entry in entries.flatten() {
2056
- let path = entry.path();
2057
- let file_name = path.file_name().unwrap().to_str().unwrap();
2058
-
2059
- // Skip files/directories matching skip patterns
2060
- if should_skip_path(&path, file_name, &self.config.skip_files) {
2061
- continue;
2062
- }
2063
-
2064
- if path.is_dir() {
2065
- let new_relative = format!("{}/{}", relative_path, file_name);
2066
- self.copy_directory(&path, &new_relative, canonical_source, stats)?;
2067
- } else {
2068
- self.copy_file(&path, relative_path)?;
2069
- stats.files_copied += 1;
2070
- if let Some(ref mut logger) = self.logger {
2071
- logger.log(&path, FileOperation::Copied, canonical_source);
2072
- }
2073
- }
2074
- }
2075
-
2076
- Ok(())
2077
- }
2078
-
2079
- /// Copy node_modules as-is to output
2080
- fn copy_node_modules_as_is(&self) -> Result<(), CompileError> {
2081
- let src = self.config.working_dir.join("node_modules");
2082
- let dest = self.config.output.join("node_modules");
2083
-
2084
- // Remove existing node_modules in output if it exists
2085
- if dest.exists() {
2086
- fs::remove_dir_all(&dest).map_err(|e| CompileError::WriteError {
2087
- path: dest.display().to_string(),
2088
- source: e,
2089
- })?;
2090
- }
2091
-
2092
- // Copy recursively
2093
- copy_dir_recursive(&src, &dest)?;
2094
-
2095
- Ok(())
2096
- }
2097
-
2098
- /// Install production dependencies directly into output directory
2099
- fn copy_production_node_modules(&self) -> Result<(), CompileError> {
2100
- let pkg_manager = detect_package_manager(&self.config.working_dir)?;
2101
-
2102
- if self.verbose {
2103
- println!("\n Detected package manager: {}", pkg_manager);
2104
- println!(" Installing production dependencies to output...");
2105
- }
2106
-
2107
- // 1. Copy package.json to output directory
2108
- let package_json_src = self.config.working_dir.join("package.json");
2109
- let package_json_dest = self.config.output.join("package.json");
2110
-
2111
- fs::copy(&package_json_src, &package_json_dest).map_err(|e| CompileError::WriteError {
2112
- path: package_json_dest.display().to_string(),
2113
- source: e,
2114
- })?;
2115
-
2116
- // 2. Copy lockfile if it exists (needed for reproducible installs)
2117
- let lockfile_name = match pkg_manager.as_str() {
2118
- "bun" => "bun.lockb",
2119
- "pnpm" => "pnpm-lock.yaml",
2120
- "yarn" => "yarn.lock",
2121
- "npm" => "package-lock.json",
2122
- _ => "",
2123
- };
2124
-
2125
- if !lockfile_name.is_empty() {
2126
- let lockfile_src = self.config.working_dir.join(lockfile_name);
2127
- let lockfile_dest = self.config.output.join(lockfile_name);
2128
- if lockfile_src.exists() {
2129
- fs::copy(&lockfile_src, &lockfile_dest).ok();
2130
- }
2131
- }
2132
-
2133
- // 3. Install production dependencies directly in output directory
2134
- run_command(&pkg_manager, &["install", "--production"], &self.config.output)?;
2135
-
2136
- // 4. Cleanup: remove package.json and lockfile from output
2137
- fs::remove_file(&package_json_dest).ok();
2138
- if !lockfile_name.is_empty() {
2139
- let lockfile_dest = self.config.output.join(lockfile_name);
2140
- fs::remove_file(&lockfile_dest).ok();
2141
- }
2142
-
2143
- if self.verbose {
2144
- println!(" Installed production dependencies to output");
2145
- }
2146
-
2147
- Ok(())
2148
- }
2149
- }
2150
-
2151
- /// Detect package manager from lockfiles
2152
- fn detect_package_manager(dir: &Path) -> Result<String, CompileError> {
2153
- if dir.join("bun.lockb").exists() {
2154
- return Ok("bun".to_string());
2155
- }
2156
- if dir.join("pnpm-lock.yaml").exists() {
2157
- return Ok("pnpm".to_string());
2158
- }
2159
- if dir.join("yarn.lock").exists() {
2160
- return Ok("yarn".to_string());
2161
- }
2162
- if dir.join("package-lock.json").exists() {
2163
- return Ok("npm".to_string());
2164
- }
2165
-
2166
- Err(CompileError::PackageManagerNotFound)
2167
- }
2168
-
2169
- /// Run a package manager command
2170
- fn run_command(pkg_manager: &str, args: &[&str], cwd: &Path) -> Result<(), CompileError> {
2171
- let output = Command::new(pkg_manager)
2172
- .args(args)
2173
- .current_dir(cwd)
2174
- .output()
2175
- .map_err(|e| CompileError::CommandError(format!("{} not found: {}", pkg_manager, e)))?;
2176
-
2177
- if !output.status.success() {
2178
- let stderr = String::from_utf8_lossy(&output.stderr);
2179
- return Err(CompileError::CommandError(format!(
2180
- "{} {} failed: {}",
2181
- pkg_manager,
2182
- args.join(" "),
2183
- stderr
2184
- )));
2185
- }
2186
-
2187
- Ok(())
2188
- }
2189
-
2190
- /// Recursively copy a directory
2191
- fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), CompileError> {
2192
- if !dest.exists() {
2193
- fs::create_dir_all(dest).map_err(|_| CompileError::CreateDirError(dest.display().to_string()))?;
2194
- }
2195
-
2196
- for entry in fs::read_dir(src).map_err(|e| CompileError::ReadError {
2197
- path: src.display().to_string(),
2198
- source: e,
2199
- })? {
2200
- let entry = entry.map_err(|e| CompileError::ReadError {
2201
- path: src.display().to_string(),
2202
- source: e,
2203
- })?;
2204
- let path = entry.path();
2205
- let file_name = entry.file_name();
2206
- let dest_path = dest.join(&file_name);
2207
-
2208
- if path.is_dir() {
2209
- copy_dir_recursive(&path, &dest_path)?;
2210
- } else {
2211
- fs::copy(&path, &dest_path).map_err(|e| CompileError::WriteError {
2212
- path: dest_path.display().to_string(),
2213
- source: e,
2214
- })?;
2215
- }
2216
- }
2217
-
2218
- Ok(())
2219
- }
2220
-
2221
- /// Basic HTML minification
2222
- fn minify_html(html: &str) -> String {
2223
- let mut result = String::with_capacity(html.len());
2224
- let mut in_raw = false;
2225
- let mut last_was_space = false;
2226
-
2227
- for line in html.lines() {
2228
- let trimmed = line.trim();
2229
-
2230
- // <pre>, <script> and <style> carry significant whitespace and must
2231
- // survive minification verbatim: <pre> is literal text, while a JS
2232
- // `//` line comment or ASI in <script> breaks the moment its trailing
2233
- // newline is collapsed into a space (the comment swallows the rest of
2234
- // the script). Keep these blocks line-for-line, exactly as <pre> always
2235
- // did. The opening-tag line enters the block before we emit it; the
2236
- // closing-tag line leaves it (and collapses, which only tightens the
2237
- // bare `</pre>` / `</script>` / `</style>`).
2238
- if trimmed.contains("<pre") || trimmed.contains("<script") || trimmed.contains("<style") {
2239
- in_raw = true;
2240
- }
2241
- if trimmed.contains("</pre>") || trimmed.contains("</script>") || trimmed.contains("</style>") {
2242
- in_raw = false;
2243
- }
2244
-
2245
- if in_raw {
2246
- result.push_str(line);
2247
- result.push('\n');
2248
- last_was_space = false;
2249
- } else {
2250
- // The line break preceding this line is whitespace: collapse it to a
2251
- // single space so attributes/text split across lines don't glue
2252
- // together (e.g. a multi-line <meta name=... content=...> tag).
2253
- // The >\s+< pass below re-tightens genuine tag boundaries.
2254
- if !last_was_space && !result.is_empty() {
2255
- result.push(' ');
2256
- last_was_space = true;
2257
- }
2258
- for c in trimmed.chars() {
2259
- if c.is_whitespace() {
2260
- if !last_was_space {
2261
- result.push(' ');
2262
- last_was_space = true;
2263
- }
2264
- } else {
2265
- result.push(c);
2266
- last_was_space = false;
2267
- }
2268
- }
2269
- }
2270
- }
2271
-
2272
- // Collapse whitespace between tags to a SINGLE space — never delete it.
2273
- // Inter-element whitespace is significant for inline content: the browser
2274
- // (and the non-compiled runtime) render `<em>a</em> <em>b</em>` with a space,
2275
- // so dropping it (`><`) glued words onto preceding inline elements (the
2276
- // status-chip "💠Concussion" parity bug). One space matches both the browser's
2277
- // own collapsing and the non-minified/runtime output; where the space is
2278
- // insignificant (between block/table/list/head elements) the parser discards
2279
- // it anyway, exactly as it does for the non-minified newline.
2280
- let result = regex::Regex::new(r">\s+<")
2281
- .unwrap()
2282
- .replace_all(&result, "> <")
2283
- .to_string();
2284
-
2285
- result.trim().to_string()
2286
- }
2287
-
2288
- fn find_bytes(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
2289
- haystack[from..].windows(needle.len()).position(|w| w == needle).map(|p| p + from)
2290
- }
2291
-
2292
- fn find_bytes_ci(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
2293
- haystack[from..].windows(needle.len()).position(|w| w.eq_ignore_ascii_case(needle)).map(|p| p + from)
2294
- }
2295
-
2296
- fn count_newlines(bytes: &[u8]) -> usize {
2297
- bytes.iter().filter(|&&b| b == b'\n').count()
2298
- }
2299
-
2300
- #[cfg(test)]
2301
- mod tests {
2302
- use super::*;
2303
-
2304
- #[test]
2305
- fn minify_preserves_script_newlines_so_line_comments_dont_swallow_code() {
2306
- // A `//` line comment inside a component script relies on its trailing
2307
- // newline. If minify collapses newlines into spaces, the comment eats
2308
- // the rest of the script → SyntaxError at runtime (new Function).
2309
- let html = "<page>\n\
2310
- <script type=\"module\">\n\
2311
- \x20 const a = 1; // explain a\n\
2312
- \x20 // keep explaining\n\
2313
- \x20 const b = 2;\n\
2314
- </script>\n\
2315
- </page>";
2316
-
2317
- let out = minify_html(html);
2318
-
2319
- // The code after the comments must still be reachable, i.e. on its own
2320
- // line rather than glued behind the `//`.
2321
- let script = &out[out.find("<script").unwrap()..out.find("</script>").unwrap()];
2322
- assert!(
2323
- script.contains('\n'),
2324
- "script newlines were collapsed, // comment swallows following code: {script:?}"
2325
- );
2326
- assert!(out.contains("const b = 2"), "code after // comment lost: {out:?}");
2327
-
2328
- // Surrounding HTML must still be minified (tag boundaries tightened).
2329
- assert!(out.contains("<page><script"), "non-script HTML not minified: {out:?}");
2330
- }
2331
-
2332
- #[test]
2333
- fn minify_preserves_style_newlines() {
2334
- let html = "<page>\n\
2335
- <style>\n\
2336
- \x20 a { color: red; }\n\
2337
- \x20 b { color: blue; }\n\
2338
- </style>\n\
2339
- </page>";
2340
-
2341
- let out = minify_html(html);
2342
- let style = &out[out.find("<style").unwrap()..out.find("</style>").unwrap()];
2343
- assert!(style.contains('\n'), "style newlines collapsed: {style:?}");
2344
- }
2345
-
2346
- #[test]
2347
- fn atomic_write_replaces_content_without_leaving_tmp_files() {
2348
- let dir = std::env::temp_dir().join("vibe_atomic_write_test");
2349
- let _ = fs::remove_dir_all(&dir);
2350
- fs::create_dir_all(&dir).unwrap();
2351
- let target = dir.join("page.html");
2352
-
2353
- atomic_write(&target, "first").unwrap();
2354
- assert_eq!(fs::read_to_string(&target).unwrap(), "first");
2355
-
2356
- // Overwriting an existing file goes through the same tmp+rename path.
2357
- atomic_write(&target, "second, longer content").unwrap();
2358
- assert_eq!(fs::read_to_string(&target).unwrap(), "second, longer content");
2359
-
2360
- let leftovers: Vec<String> = fs::read_dir(&dir)
2361
- .unwrap()
2362
- .filter_map(|e| e.ok())
2363
- .map(|e| e.file_name().to_string_lossy().into_owned())
2364
- .filter(|n| n != "page.html")
2365
- .collect();
2366
- assert!(leftovers.is_empty(), "temp artifacts left behind: {leftovers:?}");
2367
- }
2368
-
2369
- // A minimal on-disk project: source with one page carrying a distinctive
2370
- // binding, empty components dir, output dir sibling. Returns (config, page
2371
- // source path, compiled page output path, manifest path).
2372
- fn manifest_test_project(name: &str) -> (Config, PathBuf, PathBuf, PathBuf) {
2373
- let dir = std::env::temp_dir().join(format!("vibe_{}_test", name));
2374
- let _ = fs::remove_dir_all(&dir);
2375
- let source = dir.join("src");
2376
- let output = dir.join("out");
2377
- fs::create_dir_all(source.join("components")).unwrap();
2378
- fs::write(
2379
- source.join("index.html"),
2380
- "<!doctype html>\n<html><head><title>t</title></head>\n\
2381
- <body vibe>\n<page-home><h1>@[uniqueMarker123]</h1></page-home>\n</body></html>\n",
2382
- )
2383
- .unwrap();
2384
-
2385
- let config = Config {
2386
- source: source.clone(),
2387
- output: output.clone(),
2388
- _source_str: String::new(),
2389
- _output_str: String::new(),
2390
- components: "components".to_string(),
2391
- pages: "pages".to_string(),
2392
- _assets: String::new(),
2393
- root: None,
2394
- minify: false,
2395
- elements_as_is: false,
2396
- source_maps: false,
2397
- reserved_elements: Vec::new(),
2398
- skip_files: Vec::new(),
2399
- node_modules_as_is: false,
2400
- components_as_is: false,
2401
- runtime_as_is: false,
2402
- iterations_as_is: false,
2403
- no_clean: false,
2404
- fouc_as_is: false,
2405
- working_dir: dir.clone(),
2406
- };
2407
-
2408
- let page_src = source.join("index.html");
2409
- let page_out = output.join("index.html");
2410
- let manifest = output.join("vibe-hyperspeed").join("index.html.manifest.js");
2411
- (config, page_src, page_out, manifest)
2412
- }
2413
-
2414
- // The full build always mirrors components/ verbatim into the output —
2415
- // runtime-fetched components (`<component src="@[page.src]">`, iter-prop
2416
- // roots, components_as_is) are served from that mirror. Watch mode must
2417
- // keep the same contract: an edited component reaches the mirror even when
2418
- // NO page inlines it (zero dependents), and a deleted component leaves it.
2419
- #[test]
2420
- fn changed_component_is_remirrored_to_output() {
2421
- let (config, _page_src, _page_out, _manifest) = manifest_test_project("component_mirror");
2422
- let source_component = config.source.join("components").join("widget.html");
2423
- let mirrored = config.output.join("components").join("widget.html");
2424
- let nested_src = config.source.join("components").join("spa").join("pane.html");
2425
- let nested_out = config.output.join("components").join("spa").join("pane.html");
2426
- fs::write(&source_component, "<spa-widget>v1</spa-widget>\n").unwrap();
2427
-
2428
- let mut compiler = Compiler::new(config, false);
2429
- compiler.compile().expect("compile should succeed");
2430
- assert_eq!(
2431
- fs::read_to_string(&mirrored).unwrap(),
2432
- "<spa-widget>v1</spa-widget>\n",
2433
- "sanity: full build mirrors the component"
2434
- );
2435
-
2436
- // Edit the component — no page references it, so the dependency graph
2437
- // maps it to zero pages; the mirror must still track the change.
2438
- fs::write(&source_component, "<spa-widget>v2</spa-widget>\n").unwrap();
2439
- let copied = compiler
2440
- .mirror_component_files(&[source_component.clone()])
2441
- .expect("mirroring should succeed");
2442
- assert_eq!(copied, 1);
2443
- assert_eq!(
2444
- fs::read_to_string(&mirrored).unwrap(),
2445
- "<spa-widget>v2</spa-widget>\n",
2446
- "edited component did not reach the output mirror"
2447
- );
2448
-
2449
- // A component created mid-session (parent dirs may not exist yet).
2450
- fs::create_dir_all(nested_src.parent().unwrap()).unwrap();
2451
- fs::write(&nested_src, "<spa-pane>new</spa-pane>\n").unwrap();
2452
- compiler
2453
- .mirror_component_files(&[nested_src.clone()])
2454
- .expect("mirroring a new nested component should succeed");
2455
- assert_eq!(fs::read_to_string(&nested_out).unwrap(), "<spa-pane>new</spa-pane>\n");
2456
-
2457
- // Deleting the source removes the mirrored copy.
2458
- fs::remove_file(&source_component).unwrap();
2459
- compiler
2460
- .mirror_component_files(&[source_component])
2461
- .expect("mirroring a deletion should succeed");
2462
- assert!(!mirrored.exists(), "deleted component still present in the output mirror");
2463
- }
2464
-
2465
- // The watcher race: another writer truncates/rewrites a compiled page on
2466
- // disk between our compile and our manifest pass. The manifest must be
2467
- // built from the HTML this compiler just produced in memory — never from a
2468
- // disk read-back — or a torn read yields a valid-but-empty manifest and the
2469
- // page hydrates to a blank screen.
2470
- #[test]
2471
- fn manifest_survives_output_corruption_between_compile_and_manifests() {
2472
- let (config, _page_src, page_out, manifest) =
2473
- manifest_test_project("manifest_memory_full");
2474
-
2475
- let mut compiler = Compiler::new(config, false);
2476
- compiler.compile().expect("compile should succeed");
2477
- assert!(
2478
- fs::read_to_string(&page_out).unwrap().contains("uniqueMarker123"),
2479
- "sanity: compiled page carries the binding"
2480
- );
2481
-
2482
- // Simulate the concurrent writer: the on-disk page is now a shell.
2483
- fs::write(&page_out, "<!doctype html>\n<html><head></head><body></body></html>\n").unwrap();
2484
-
2485
- compiler.generate_manifests().expect("manifest generation should succeed");
2486
-
2487
- let manifest_js = fs::read_to_string(&manifest).expect("manifest should exist");
2488
- assert!(
2489
- manifest_js.contains("uniqueMarker123"),
2490
- "manifest was built from the corrupted disk file instead of the in-memory compile output"
2491
- );
2492
- }
2493
-
2494
- // Same property on the incremental watch path (generate_manifests_for_files),
2495
- // which is where the two-watcher race actually corrupted manifests.
2496
- #[test]
2497
- fn incremental_manifest_survives_output_corruption() {
2498
- let (config, page_src, page_out, manifest) =
2499
- manifest_test_project("manifest_memory_incremental");
2500
-
2501
- let mut compiler = Compiler::new(config.clone(), false);
2502
- let mut parser = HtmlParser::new(config.components_path());
2503
- parser.load_elements().unwrap();
2504
-
2505
- fs::create_dir_all(&config.output).unwrap();
2506
- compiler
2507
- .compile_specific_html_files(&[page_src.clone()], &parser)
2508
- .expect("incremental compile should succeed");
2509
-
2510
- fs::write(&page_out, "<!doctype html>\n<html><head></head><body></body></html>\n").unwrap();
2511
-
2512
- compiler
2513
- .generate_manifests_for_files(&[page_src])
2514
- .expect("incremental manifest generation should succeed");
2515
-
2516
- let manifest_js = fs::read_to_string(&manifest).expect("manifest should exist");
2517
- assert!(
2518
- manifest_js.contains("uniqueMarker123"),
2519
- "incremental manifest was built from the corrupted disk file instead of the in-memory compile output"
2520
- );
2521
- }
2522
- }