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