@ape-egg/vibe-compiler 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.rs ADDED
@@ -0,0 +1,286 @@
1
+ use serde::{Deserialize, Serialize};
2
+ use std::fs;
3
+ use std::path::PathBuf;
4
+ use thiserror::Error;
5
+
6
+ #[derive(Error, Debug)]
7
+ pub enum ConfigError {
8
+ #[error("Failed to read package.json: {0}")]
9
+ ReadError(#[from] std::io::Error),
10
+ #[error("Failed to parse package.json: {0}")]
11
+ ParseError(#[from] serde_json::Error),
12
+ }
13
+
14
+ #[derive(Debug, Clone, Serialize, Deserialize)]
15
+ #[serde(rename_all = "camelCase")]
16
+ pub struct VibeCompilerConfig {
17
+ #[serde(default = "default_source")]
18
+ pub source: String,
19
+ #[serde(default = "default_output")]
20
+ pub output: String,
21
+ #[serde(default = "default_components")]
22
+ pub components: String,
23
+ #[serde(default = "default_pages")]
24
+ pub pages: String,
25
+ #[serde(default = "default_assets")]
26
+ pub assets: String,
27
+ #[serde(default)]
28
+ pub root: Option<String>,
29
+ #[serde(default)]
30
+ pub minify: bool,
31
+ #[serde(default)]
32
+ pub elements_as_is: bool,
33
+ #[serde(default)]
34
+ pub source_maps: bool,
35
+ #[serde(default)]
36
+ pub reserved_elements: Vec<String>,
37
+ #[serde(default)]
38
+ pub skip_files: Vec<String>,
39
+ #[serde(default)]
40
+ pub node_modules_as_is: bool,
41
+ #[serde(default)]
42
+ pub components_as_is: bool,
43
+ #[serde(default)]
44
+ pub runtime_as_is: bool,
45
+ #[serde(default)]
46
+ pub iterations_as_is: bool,
47
+ #[serde(default)]
48
+ pub no_clean: bool,
49
+ #[serde(default)]
50
+ pub fouc_as_is: bool,
51
+ #[serde(default, deserialize_with = "deserialize_spa")]
52
+ pub spa: bool,
53
+ }
54
+
55
+ // "spa" is a boolean. An object form (a hypothetical future per-page
56
+ // selection config) has NO semantics on this compiler — guessing (treating it
57
+ // as `true`) would silently convert the entire pages tree when the author
58
+ // asked for a subset. Unsupported input is a hard, targeted error; the rest
59
+ // of the vibe-compiler section still parses.
60
+ fn deserialize_spa<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<bool, D::Error> {
61
+ match serde_json::Value::deserialize(deserializer)? {
62
+ serde_json::Value::Bool(enabled) => Ok(enabled),
63
+ other => Err(serde::de::Error::custom(format!(
64
+ "\"spa\" must be true or false — got {other}. This compiler has no per-page SPA selection; remove the object form or upgrade."
65
+ ))),
66
+ }
67
+ }
68
+
69
+ fn default_source() -> String { "./".to_string() }
70
+ fn default_output() -> String { "./compiled".to_string() }
71
+ fn default_components() -> String { "components".to_string() }
72
+ fn default_pages() -> String { "pages".to_string() }
73
+ fn default_assets() -> String { "assets".to_string() }
74
+
75
+ /// Get list of standard HTML5 elements + "component"
76
+ /// Component names matching these are reserved and not allowed
77
+ pub fn get_default_reserved_elements() -> Vec<String> {
78
+ vec![
79
+ "a", "abbr", "address", "area", "article", "aside", "audio",
80
+ "b", "base", "bdi", "bdo", "blockquote", "body", "br", "button",
81
+ "canvas", "caption", "cite", "code", "col", "colgroup",
82
+ "data", "datalist", "dd", "del", "details", "dfn", "dialog", "div", "dl", "dt",
83
+ "em", "embed",
84
+ "fieldset", "figcaption", "figure", "footer", "form",
85
+ "h1", "h2", "h3", "h4", "h5", "h6", "head", "header", "hgroup", "hr", "html",
86
+ "i", "iframe", "img", "input", "ins",
87
+ "kbd",
88
+ "label", "legend", "li", "link",
89
+ "main", "map", "mark", "menu", "meta", "meter",
90
+ "nav", "noscript",
91
+ "object", "ol", "optgroup", "option", "output",
92
+ "p", "param", "picture", "pre", "progress",
93
+ "q",
94
+ "rp", "rt", "ruby",
95
+ "s", "samp", "script", "search", "section", "select", "slot", "small", "source", "span", "strong", "style", "sub", "summary", "sup", "svg",
96
+ "table", "tbody", "td", "template", "textarea", "tfoot", "th", "thead", "time", "title", "tr", "track",
97
+ "u", "ul",
98
+ "var", "video",
99
+ "wbr",
100
+ "component", // Always reserved
101
+ ].into_iter().map(|s| s.to_string()).collect()
102
+ }
103
+
104
+ impl Default for VibeCompilerConfig {
105
+ fn default() -> Self {
106
+ Self {
107
+ source: default_source(),
108
+ output: default_output(),
109
+ components: default_components(),
110
+ pages: default_pages(),
111
+ assets: default_assets(),
112
+ root: None,
113
+ minify: false,
114
+ elements_as_is: false,
115
+ source_maps: false,
116
+ reserved_elements: vec![],
117
+ skip_files: vec![],
118
+ node_modules_as_is: false,
119
+ components_as_is: false,
120
+ runtime_as_is: false,
121
+ iterations_as_is: false,
122
+ no_clean: false,
123
+ fouc_as_is: false,
124
+ spa: false,
125
+ }
126
+ }
127
+ }
128
+
129
+ #[derive(Debug, Clone, PartialEq)]
130
+ pub struct Config {
131
+ pub source: PathBuf,
132
+ pub output: PathBuf,
133
+ pub _source_str: String, // Original config value for display
134
+ pub _output_str: String, // Original config value for display
135
+ pub components: String,
136
+ pub pages: String,
137
+ pub _assets: String,
138
+ pub root: Option<String>,
139
+ pub minify: bool,
140
+ pub elements_as_is: bool,
141
+ pub source_maps: bool,
142
+ pub reserved_elements: Vec<String>,
143
+ pub skip_files: Vec<String>,
144
+ pub node_modules_as_is: bool,
145
+ pub components_as_is: bool,
146
+ pub runtime_as_is: bool,
147
+ pub iterations_as_is: bool,
148
+ pub no_clean: bool,
149
+ pub fouc_as_is: bool,
150
+ pub spa: bool,
151
+ pub working_dir: PathBuf,
152
+ }
153
+
154
+ impl Config {
155
+ /// Load config from package.json, or use defaults if not found
156
+ pub fn load(working_dir: PathBuf) -> Self {
157
+ // A relative --cwd must become absolute HERE: every derived path
158
+ // (source, output, pages) joins from working_dir, and downstream
159
+ // strip_prefix against filesystem-event paths (always absolute)
160
+ // otherwise fails — in watch mode that once routed a manifest write
161
+ // and a stamped page INTO the source tree.
162
+ let working_dir = working_dir.canonicalize().unwrap_or(working_dir);
163
+ let package_path = working_dir.join("package.json");
164
+
165
+ let config = if package_path.exists() {
166
+ fs::read_to_string(&package_path)
167
+ .ok()
168
+ .and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
169
+ .and_then(|package| package.get("vibe-compiler").cloned())
170
+ .and_then(|vibe_config| serde_json::from_value::<VibeCompilerConfig>(vibe_config).ok())
171
+ .unwrap_or_default()
172
+ } else {
173
+ VibeCompilerConfig::default()
174
+ };
175
+
176
+ let source_str = config.source.clone();
177
+ let output_str = config.output.clone();
178
+ let source = working_dir.join(&config.source);
179
+ let output = working_dir.join(&config.output);
180
+
181
+ // Combine default reserved elements with user-provided ones
182
+ let mut reserved_elements = get_default_reserved_elements();
183
+ reserved_elements.extend(config.reserved_elements);
184
+
185
+ // Combine built-in skip patterns with user-provided ones (append, not replace)
186
+ let mut skip_files: Vec<String> = crate::compiler::compile::SKIP_FILES
187
+ .iter()
188
+ .map(|s| s.to_string())
189
+ .collect();
190
+ skip_files.extend(config.skip_files);
191
+
192
+ Self {
193
+ source,
194
+ output,
195
+ _source_str: source_str,
196
+ _output_str: output_str,
197
+ components: config.components,
198
+ pages: config.pages,
199
+ _assets: config.assets,
200
+ root: config.root,
201
+ minify: config.minify,
202
+ elements_as_is: config.elements_as_is,
203
+ source_maps: config.source_maps,
204
+ reserved_elements,
205
+ skip_files,
206
+ node_modules_as_is: config.node_modules_as_is,
207
+ components_as_is: config.components_as_is,
208
+ runtime_as_is: config.runtime_as_is,
209
+ iterations_as_is: config.iterations_as_is,
210
+ no_clean: config.no_clean,
211
+ fouc_as_is: config.fouc_as_is,
212
+ spa: config.spa,
213
+ working_dir,
214
+ }
215
+ }
216
+
217
+ pub fn components_path(&self) -> PathBuf {
218
+ self.source.join(&self.components)
219
+ }
220
+
221
+ pub fn _pages_path(&self) -> PathBuf {
222
+ self.source.join(&self.pages)
223
+ }
224
+
225
+ pub fn _assets_path(&self) -> PathBuf {
226
+ self.source.join(&self._assets)
227
+ }
228
+ }
229
+
230
+ #[cfg(test)]
231
+ mod tests {
232
+ use super::*;
233
+
234
+ fn parse(json: &str) -> VibeCompilerConfig {
235
+ serde_json::from_str(json).unwrap()
236
+ }
237
+
238
+ #[test]
239
+ fn spa_parses_bool_and_rejects_unsupported_forms() {
240
+ assert!(!parse(r#"{}"#).spa);
241
+ assert!(parse(r#"{"spa": true}"#).spa);
242
+ assert!(!parse(r#"{"spa": false}"#).spa);
243
+ // An object form has no semantics on this compiler — guessing would
244
+ // silently convert the whole pages tree. Hard, targeted error.
245
+ let err = serde_json::from_str::<VibeCompilerConfig>(r#"{"spa": {"pages": ["docs"]}}"#)
246
+ .unwrap_err()
247
+ .to_string();
248
+ assert!(err.contains("\"spa\" must be true or false"));
249
+ }
250
+ }
251
+
252
+ pub fn init_config(working_dir: &PathBuf) -> Result<(), ConfigError> {
253
+ let package_path = working_dir.join("package.json");
254
+
255
+ if !package_path.exists() {
256
+ // Create minimal package.json with vibe-compiler config
257
+ let config = VibeCompilerConfig::default();
258
+ let package = serde_json::json!({
259
+ "vibe-compiler": config
260
+ });
261
+ let formatted = serde_json::to_string_pretty(&package)?;
262
+ fs::write(&package_path, formatted)?;
263
+ println!("Created package.json with vibe-compiler config");
264
+ return Ok(());
265
+ }
266
+
267
+ let content = fs::read_to_string(&package_path)?;
268
+ let mut package: serde_json::Value = serde_json::from_str(&content)?;
269
+
270
+ // Don't overwrite existing config
271
+ if package.get("vibe-compiler").is_some() {
272
+ println!("vibe-compiler config already exists in package.json");
273
+ return Ok(());
274
+ }
275
+
276
+ let default_config = VibeCompilerConfig::default();
277
+ let config_value = serde_json::to_value(&default_config)?;
278
+
279
+ package["vibe-compiler"] = config_value;
280
+
281
+ let formatted = serde_json::to_string_pretty(&package)?;
282
+ fs::write(&package_path, formatted)?;
283
+
284
+ println!("Added vibe-compiler config to package.json");
285
+ Ok(())
286
+ }
package/src/main.rs ADDED
@@ -0,0 +1,358 @@
1
+ mod config;
2
+ mod parser;
3
+ mod compiler;
4
+
5
+ use clap::Parser as ClapParser;
6
+ use colored::Colorize;
7
+ use std::path::PathBuf;
8
+
9
+ use config::Config;
10
+ use compiler::Compiler;
11
+
12
+ /// Track which config values were overridden by flags
13
+ #[derive(Debug, Default)]
14
+ struct ConfigOverrides {
15
+ minify: bool,
16
+ elements_as_is: bool,
17
+ source_maps: bool,
18
+ node_modules_as_is: bool,
19
+ components_as_is: bool,
20
+ runtime_as_is: bool,
21
+ iterations_as_is: bool,
22
+ spa: bool,
23
+ }
24
+
25
+ /// Vibe Compiler - Compiles Vibe source files into optimized output
26
+ #[derive(ClapParser, Debug)]
27
+ #[command(name = "vibe-compile")]
28
+ #[command(author = "Kim Korte")]
29
+ #[command(version)]
30
+ #[command(about = "Compiles Vibe source files into optimized output")]
31
+ struct Args {
32
+ /// Working directory (defaults to current directory)
33
+ #[arg(long)]
34
+ cwd: Option<PathBuf>,
35
+
36
+ /// Watch for file changes
37
+ #[arg(long)]
38
+ watch: bool,
39
+
40
+ /// Minify output
41
+ #[arg(long)]
42
+ minify: bool,
43
+
44
+ /// Enable verbose logging with step-by-step output
45
+ #[arg(long)]
46
+ verbose: bool,
47
+
48
+ /// Keep custom HTML elements as-is (don't transform to divs for accessibility)
49
+ #[arg(long, name = "elements-as-is")]
50
+ elements_as_is: bool,
51
+
52
+ /// Generate source maps
53
+ #[arg(long, name = "source-maps")]
54
+ source_maps: bool,
55
+
56
+ /// Initialize configuration in package.json
57
+ #[arg(long)]
58
+ init: bool,
59
+
60
+ /// Custom source directory (overrides config)
61
+ #[arg(long)]
62
+ source: Option<PathBuf>,
63
+
64
+ /// Custom output directory (overrides config)
65
+ #[arg(long)]
66
+ output: Option<PathBuf>,
67
+
68
+ /// Copy node_modules as-is without production-only install (overrides config)
69
+ #[arg(long, name = "node-modules-as-is")]
70
+ node_modules_as_is: bool,
71
+
72
+ /// Keep components directory and <component> tags as-is (don't inline components)
73
+ #[arg(long, name = "components-as-is")]
74
+ components_as_is: bool,
75
+
76
+ /// Skip manifest generation - output will use runtime-only mode (no FOUC prevention)
77
+ #[arg(long, name = "runtime-as-is")]
78
+ runtime_as_is: bool,
79
+
80
+ /// Skip iteration optimization - use runtime DOM cloning instead of compiled batch functions
81
+ #[arg(long, name = "iterations-as-is")]
82
+ iterations_as_is: bool,
83
+
84
+ /// Skip cleaning output directory before compilation
85
+ #[arg(long, name = "no-clean", hide = true)]
86
+ no_clean: bool,
87
+
88
+ /// Keep FOUC prevention class/attribute in compiled output
89
+ #[arg(long, name = "fouc-as-is")]
90
+ fouc_as_is: bool,
91
+
92
+ /// Compile the pages tree to SPA output: fragments + route table + shell
93
+ #[arg(long)]
94
+ spa: bool,
95
+ }
96
+
97
+ fn main() {
98
+ let args = Args::parse();
99
+
100
+ // Determine working directory
101
+ let working_dir = args.cwd
102
+ .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
103
+
104
+ if args.init {
105
+ match config::init_config(&working_dir) {
106
+ Ok(_) => {
107
+ return;
108
+ }
109
+ Err(e) => {
110
+ eprintln!("{}: {}", "Error".red(), e);
111
+ std::process::exit(1);
112
+ }
113
+ }
114
+ }
115
+
116
+ // Load config from package.json (uses defaults if not found)
117
+ let mut config = Config::load(working_dir);
118
+
119
+ // Override config with CLI args
120
+ if let Some(source) = args.source {
121
+ config.source = config.working_dir.join(source);
122
+ }
123
+ if let Some(output) = args.output {
124
+ config.output = config.working_dir.join(output);
125
+ }
126
+
127
+ // Store original config values before applying flag overrides
128
+ let original_minify = config.minify;
129
+ let original_elements_as_is = config.elements_as_is;
130
+ let original_source_maps = config.source_maps;
131
+ let original_node_modules_as_is = config.node_modules_as_is;
132
+ let original_components_as_is = config.components_as_is;
133
+ let original_runtime_as_is = config.runtime_as_is;
134
+ let original_iterations_as_is = config.iterations_as_is;
135
+ let original_spa = config.spa;
136
+
137
+ // Track overrides for verbose output
138
+ let mut overrides = ConfigOverrides::default();
139
+
140
+ if args.minify {
141
+ overrides.minify = !original_minify;
142
+ config.minify = true;
143
+ }
144
+ if args.elements_as_is {
145
+ overrides.elements_as_is = !original_elements_as_is;
146
+ config.elements_as_is = true;
147
+ }
148
+ if args.source_maps {
149
+ overrides.source_maps = !original_source_maps;
150
+ config.source_maps = true;
151
+ }
152
+ if args.node_modules_as_is {
153
+ overrides.node_modules_as_is = !original_node_modules_as_is;
154
+ config.node_modules_as_is = true;
155
+ }
156
+ if args.components_as_is {
157
+ overrides.components_as_is = !original_components_as_is;
158
+ config.components_as_is = true;
159
+ }
160
+ if args.runtime_as_is {
161
+ overrides.runtime_as_is = !original_runtime_as_is;
162
+ config.runtime_as_is = true;
163
+ }
164
+ if args.iterations_as_is {
165
+ overrides.iterations_as_is = !original_iterations_as_is;
166
+ config.iterations_as_is = true;
167
+ }
168
+ if args.no_clean {
169
+ config.no_clean = true;
170
+ }
171
+ if args.fouc_as_is {
172
+ config.fouc_as_is = true;
173
+ }
174
+ if args.spa {
175
+ overrides.spa = !original_spa;
176
+ config.spa = true;
177
+ }
178
+
179
+ if args.verbose {
180
+ println!("{}", format!("Vibe Compiler v{}", env!("CARGO_PKG_VERSION")).cyan().bold());
181
+ println!();
182
+ println!(" {}: {}", "Working dir".cyan(), config.working_dir.display());
183
+ println!(" {}: {}", "Source".cyan(), config.source.display());
184
+ println!(" {}: {}", "Output".cyan(), config.output.display());
185
+ println!(" {}: {}", "Components".cyan(), config.components);
186
+ println!(" {}: {}", "Pages".cyan(), config.pages);
187
+ println!(" {}: {}", "Assets".cyan(), config._assets);
188
+ println!();
189
+
190
+ // Show config values (alphabetically ordered)
191
+ println!("{}", "Compiler config".cyan().bold());
192
+ println!();
193
+ println!(" CLI flags override package.json config");
194
+ println!();
195
+
196
+ fn format_bool_with_flag(final_value: bool, overridden: bool, original_value: bool) -> String {
197
+ if overridden {
198
+ format!("{} (config: {}, flag: {})", final_value.to_string().green(), original_value.to_string().yellow(), final_value.to_string().green())
199
+ } else {
200
+ format!("{} (config: {})", final_value.to_string().green(), original_value.to_string().yellow())
201
+ }
202
+ }
203
+
204
+ fn format_value_no_flag<T: std::fmt::Display>(value: T) -> String {
205
+ format!("{} (config: {})", value.to_string().green(), value.to_string().yellow())
206
+ }
207
+
208
+ fn format_reserved_elements(elements: &[String]) -> String {
209
+ if elements.len() <= 2 {
210
+ format!("{:?}", elements)
211
+ } else {
212
+ let remaining = elements.len() - 2;
213
+ format!("[\"component\", \"div\", ... + {} more]", remaining)
214
+ }
215
+ }
216
+
217
+ fn format_list_preview(items: &[String]) -> String {
218
+ if items.len() <= 2 {
219
+ format!("{:?}", items)
220
+ } else {
221
+ format!("[{:?}, {:?}, ... + {} more]", items[0], items[1], items.len() - 2)
222
+ }
223
+ }
224
+
225
+ // Alphabetically ordered with padding (longest key is "reservedElements" = 16 chars)
226
+ println!(" {}: {}", format!("{:<16}", "assets").cyan(), format_value_no_flag(&config._assets));
227
+ println!(" {}: {}", format!("{:<16}", "components").cyan(), format_value_no_flag(&config.components));
228
+ println!(" {}: {}", format!("{:<16}", "componentsAsIs").cyan(), format_bool_with_flag(config.components_as_is, overrides.components_as_is, original_components_as_is));
229
+ println!(" {}: {}", format!("{:<16}", "elementsAsIs").cyan(), format_bool_with_flag(config.elements_as_is, overrides.elements_as_is, original_elements_as_is));
230
+ println!(" {}: {}", format!("{:<16}", "iterationsAsIs").cyan(), format_bool_with_flag(config.iterations_as_is, overrides.iterations_as_is, original_iterations_as_is));
231
+ println!(" {}: {}", format!("{:<16}", "minify").cyan(), format_bool_with_flag(config.minify, overrides.minify, original_minify));
232
+ println!(" {}: {}", format!("{:<16}", "nodeModulesAsIs").cyan(), format_bool_with_flag(config.node_modules_as_is, overrides.node_modules_as_is, original_node_modules_as_is));
233
+ println!(" {}: {}", format!("{:<16}", "output").cyan(), format_value_no_flag(&config._output_str));
234
+ println!(" {}: {}", format!("{:<16}", "pages").cyan(), format_value_no_flag(&config.pages));
235
+ println!(" {}: {}", format!("{:<16}", "reservedElements").cyan(), format_value_no_flag(format_reserved_elements(&config.reserved_elements)));
236
+ println!(" {}: {}", format!("{:<16}", "root").cyan(), format_value_no_flag(config.root.as_ref().map(|s| s.as_str()).unwrap_or("null")));
237
+ println!(" {}: {}", format!("{:<16}", "runtimeAsIs").cyan(), format_bool_with_flag(config.runtime_as_is, overrides.runtime_as_is, original_runtime_as_is));
238
+ println!(" {}: {}", format!("{:<16}", "skipFiles").cyan(), format_value_no_flag(format_list_preview(&config.skip_files)));
239
+ println!(" {}: {}", format!("{:<16}", "source").cyan(), format_value_no_flag(&config._source_str));
240
+ println!(" {}: {}", format!("{:<16}", "sourceMaps").cyan(), format_bool_with_flag(config.source_maps, overrides.source_maps, original_source_maps));
241
+ println!(" {}: {}", format!("{:<16}", "spa").cyan(), format_bool_with_flag(config.spa, overrides.spa, original_spa));
242
+ println!();
243
+ } else {
244
+ // Show version in non-verbose mode
245
+ println!("{}", format!("Vibe Compiler v{}", env!("CARGO_PKG_VERSION")).cyan());
246
+ }
247
+
248
+ // Handle watch mode
249
+ if args.watch {
250
+ if let Err(e) = compiler::watcher::watch(config, args.verbose) {
251
+ eprintln!("{}: {}", "Error".red(), e);
252
+ std::process::exit(1);
253
+ }
254
+ return;
255
+ }
256
+
257
+ // Save runtime_as_is before moving config
258
+ let runtime_as_is = config.runtime_as_is;
259
+
260
+ let mut compiler = Compiler::new(config, args.verbose);
261
+
262
+ match compiler.compile() {
263
+ Ok(stats) => {
264
+ // Generate manifests BEFORE showing success (unless runtime-as-is is enabled)
265
+ let mut manifest_time_ms = 0.0;
266
+ let mut manifest_stats_result = None;
267
+ if !runtime_as_is {
268
+ match compiler.generate_manifests() {
269
+ Ok(manifest_stats) => {
270
+ manifest_time_ms = manifest_stats.total_time_ms;
271
+ manifest_stats_result = Some(manifest_stats);
272
+ }
273
+ Err(e) => {
274
+ eprintln!("\n{}: Manifest generation failed: {}", "Warning".yellow(), e);
275
+ eprintln!("Compilation succeeded but manifests were not generated.");
276
+ eprintln!("Use --runtime-as-is to skip manifest generation.");
277
+ }
278
+ }
279
+ }
280
+
281
+ // Show success headline
282
+ println!("\n{}", format!("Compilation successful! (v{}) ✅", env!("CARGO_PKG_VERSION")).green().bold());
283
+ println!();
284
+
285
+ // Show individual phase timings (validation first, then components, HTML, manifests, copied)
286
+
287
+ // Show validation time if it happened
288
+ if let Some(validation_time) = stats.validation_time_ms {
289
+ println!("* Validated components in {:.0}ms", validation_time);
290
+ }
291
+
292
+ let total_components_unique = stats.internal_components_unique + stats.external_components_unique;
293
+
294
+ // Always show components line
295
+ if stats.components_as_is {
296
+ println!("* Compiled components (0 internal, 0 external) - \"components-as-is\": true");
297
+ } else if total_components_unique > 0 {
298
+ println!("* Compiled components ({} internal, {} external) in {:.0}ms",
299
+ stats.internal_components_unique,
300
+ stats.external_components_unique,
301
+ stats.components_time_ms
302
+ );
303
+ }
304
+
305
+ if stats.files_compiled > 0 {
306
+ println!("* Compiled HTML ({} file{}) in {:.0}ms",
307
+ stats.files_compiled,
308
+ if stats.files_compiled == 1 { "" } else { "s" },
309
+ stats.compile_time_ms
310
+ );
311
+ }
312
+
313
+ // Show manifest stats between HTML and Copied files
314
+ if let Some(manifest_stats) = manifest_stats_result {
315
+ println!("* Generated manifests ({} file{}, {} skipped) in {:.0}ms",
316
+ manifest_stats.pages_processed,
317
+ if manifest_stats.pages_processed == 1 { "" } else { "s" },
318
+ manifest_stats.pages_skipped,
319
+ manifest_stats.total_time_ms
320
+ );
321
+ }
322
+
323
+ if stats.files_copied > 0 {
324
+ println!("* Copied files ({} file{}) in {:.0}ms",
325
+ stats.files_copied,
326
+ if stats.files_copied == 1 { "" } else { "s" },
327
+ stats.copy_time_ms
328
+ );
329
+ }
330
+
331
+ // Show node_modules handling
332
+ if let Some(nm_time) = stats.node_modules_time_ms {
333
+ if stats.node_modules_copied_as_is {
334
+ println!("* copied node_modules in {:.0}ms", nm_time);
335
+ } else if let Some(ref pkg_manager) = stats.package_manager {
336
+ println!("* {} install in {:.0}ms", pkg_manager, nm_time);
337
+ }
338
+ }
339
+
340
+ // Calculate total duration as sum of all individual operations
341
+ let total_duration_ms = stats.validation_time_ms.unwrap_or(0.0)
342
+ + stats.components_time_ms
343
+ + stats.compile_time_ms
344
+ + stats.copy_time_ms
345
+ + stats.node_modules_time_ms.unwrap_or(0.0)
346
+ + manifest_time_ms;
347
+
348
+ println!("\n{} in {:.0}ms", "Compiled".green(), total_duration_ms);
349
+
350
+ // Exit explicitly to kill background server thread
351
+ std::process::exit(0);
352
+ }
353
+ Err(e) => {
354
+ eprintln!("{}: {}", "Error".red(), e);
355
+ std::process::exit(1);
356
+ }
357
+ }
358
+ }